Compare commits

...
105 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 1633245769 dvc: bump to 1.6.6 2020-08-31 03:16:40 +03:00
Ruslan Kuprieiev f52763a0b3 dvc: use flatten-dict instead of flatten-json (#4500)
* dvc: use flatten-dict instead of flatten-json

Fixes #4499

* utils: add [un]flatten

* pyinstaller: add flatten-dict hook
2020-08-31 03:15:52 +03:00
Ruslan Kuprieiev 4ee3d0d602 dvc: bump to 1.6.5 2020-08-31 00:20:15 +03:00
karajan1001 777a23afac Keep status output format consistents (#4490)
fix#4482
According to the discussion. Make `DVC status [outputs]` output format the same with `DVC status [stages]` instead of `DVC status -c [outputs]`
2020-08-31 00:03:21 +03:00
Peter Rowlands (변기호)andRuslan Kuprieiev e2b5ab996f experiments: make dvc exp show more consistent w/viewer (#4489)
* experiments: cleanup show UI

* show git tag/branch names instead of SHAs by default
* make timestamp formatting more like the viewer

* update tests

* place timestamps in their own column

* update command test

* exp: remove unused regex

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-08-31 00:02:57 +03:00
Ruslan Kuprieiev fcbbfac7f8 dvc: show "Having any troubles?" only on unexpected errors (#4496)
* dvc: dont catch generic exceptions

* dvc: show "Having any troubles?" only on unexpected errors

Fixes #4126
2020-08-30 21:08:12 +03:00
Ruslan KuprieievandSaugat Pachhai 1a35128457 dvc: use HashInfo (#4495)
* dvc: use HashInfo

Related to #4144 , #3069 , #1676

* Update dvc/tree/s3.py

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

Co-authored-by: Saugat Pachhai <[email protected]>
2020-08-30 21:07:57 +03:00
Ruslan Kuprieiev 69c63a8374 dvc: bump to 1.6.4 2020-08-29 21:55:22 +03:00
Ruslan Kuprieiev 4013256c91 external: avoid unnecessary hash computation (#4486)
* dvc: use cache state when saving downloaded cache

Related to #4485

* tree: dont compute hash from scratch for output dirs

Fixes #4485

* tree: fix get_hash
2020-08-29 21:15:44 +03:00
Peter Rowlands (변기호) d6bb4341dd plots: add dvc plots -e/--experiment option (#4488)
* plots: add -e/--experiment option for plots diff

* experiments: add method to get baseline for a given experiment

* plots: default to diff against baseline instead of workspace/head for experiments

* use exact match for experiments RE since name-rev may return relative
commits
2020-08-29 00:57:28 +03:00
Ruslan Kuprieiev be2e0778b4 dvc: bump to 1.6.3 2020-08-28 05:18:18 +03:00
Ruslan Kuprieiev a61291039e tests: use s3 client instead of resource 2020-08-28 05:16:06 +03:00
Ruslan Kuprieiev d5fcf7377d dvc: bump to 1.6.2 2020-08-27 23:11:12 +03:00
Ruslan KuprieievandJorge Orpinel b9d685a650 s3: provide more helpful messages on common errors (#4480)
* s3: provide more helpful messages on common errors

Fix #4478

* utils: introduce error_link

* Update dvc/tree/s3.py

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

* fix tests

* dont forget the link

* remove redundant message

Co-authored-by: Jorge Orpinel <[email protected]>
2020-08-27 23:09:12 +03:00
Saugat Pachhai 334556f07d tests: speedup lock tests (#4471)
* tests: speedup lock tests

Patched to reduce timeout from default 5s to 0.1s

* Reduce timeout further to 0.01s
2020-08-27 05:31:25 +03:00
Saugat Pachhai 2e47674a46 tests: install hook just before using it (#4472)
Before, we were installing hooks at the start which was making checkouts
slower. With this change, it should improve these tests by twice as much.
2020-08-26 20:24:28 +03:00
Puneetha Pai c31ec3038a diff: Add sort logic to sort diff ouput irrespective of output format… (#4459)
* diff: Add sort logic to sort diff ouput irrespective of output format(default/json/md)

* diff: test stub using actual value, not pass by reference for (dict)
2020-08-26 19:35:06 +03:00
Saugat Pachhai 54deca73d3 modify release drafter template (#4474)
With this change, release-drafter will be able to categorically
write/create changelogs based on the labels.
2020-08-26 21:35:46 +05:45
nik123 1d9acdc8f3 status: no warnings for missing state (#4470) 2020-08-26 17:17:43 +03:00
Alexander SchepanovskiandRuslan Kuprieiev bc7d952e88 Speed up dvc files load (#4417)
* Speed up dvc files load

See `._load()` implementation. Alternative is not using 3 io calls, but
simply use open and catch exceptions.

Reading is slow for old git revisions.

* tree: git: cache tree and objects

* external: ignore PermissionError on Windows

This is the same thing we have in tests.

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-08-26 05:44:27 +03:00
Ruslan Kuprieiev 718c9c98ce serialization: dont rely on the default hash type (#4466)
Prerequisite for #4144 and #3069
2020-08-25 18:34:09 +03:00
karajan1001andSaugat Pachhai fcca6361ed Outputs as target supporting for dvc status (#4433)
* Outputs as target supporting for `dvc status`

fix #4191
1. Add a related test which would fail on current version.

* Pass the tests

1. add deps to the tests.
2. make change to pass the tests.

* Update tests/func/test_status.py

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

* Solve some change request.

Co-authored-by: Saugat Pachhai <[email protected]>
2020-08-25 13:51:27 +02:00
Peter Rowlands (변기호) 714503a9e8 experiments: only show queued experiments for the current commit (#4462) 2020-08-25 14:33:46 +03:00
Peter Rowlands (변기호) 01a6580b3d experiments: fix inconsistent dvc exp checkout behavior (#4461)
* experiments: stage workspace changes before using stash apply

* add test for dvc exp checkout
2020-08-25 14:33:14 +03:00
Ruslan Kuprieiev 026c4fa47d dvc: bump to 1.6.1 2020-08-25 02:03:35 +03:00
Ruslan Kuprieiev 6c352eeaf0 hdfs: implement isdir and isfile (#4460)
Fixes #4332
2020-08-25 02:02:33 +03:00
Peter Rowlands (변기호) f789cdff89 experiments: fix bug where wrong git branch checked-out before pull (#4450) 2020-08-24 15:47:25 +03:00
Peter Rowlands (변기호) bfb98e9288 experiments: support binary and untracked files when generating (#4452)
experiment patches
2020-08-24 15:19:57 +03:00
Paweł Redzyński a82d6e4173 metrics: don't raise on dir during collection (#4443) 2020-08-24 02:55:20 +03:00
Puneetha Pai 05c1e76091 diff command: output in markdown format with hash (#4435)
* diff: Add tests to hash formatting function

* diff: Add test for show_md with hash

* diff: Add stub test for _show_md from command
2020-08-24 02:25:09 +03:00
Saugat Pachhai adce620621 add json serialization utils (#4441)
* add json serialization utils

* Update dvc/utils/serialize/_json.py
2020-08-21 17:48:26 +03:00
Saugat Pachhai 1c14a7e202 metadata() on RepoTree (#4434)
* WIP: metadata api on RepoTree

* Make outs optional

* add tests + misc. changes

* reorder tests and fields
2020-08-21 17:34:35 +03:00
Peter Rowlands (변기호) dac17e247d experiments: abstract executor tmpdir population using tree upload (#4432)
* executor: abstract tmpdir population using tree upload

* experiments: unmark flaky test

* fix wrong tree download

* LocalTree: use os.replace instead of os.rename in _upload
2020-08-21 17:32:39 +03:00
Ruslan Kuprieiev d89c16dfe8 deps: freeze pylint at 2.5.3 2020-08-21 17:31:36 +03:00
Peter Rowlands (변기호) a2a6f8bd58 tree: add overwrite parameter in tree.upload (#4442)
* tree: add overwrite flag for tree.upload

* tests: update tests

* fix test file permissions

* default to overwrite on upload
2020-08-21 17:30:27 +03:00
Saugat Pachhai 1d196b4df6 Modify yaml/toml using contextmanager, removing boilerplate (#4426) 2020-08-21 04:47:58 +03:00
Saugat Pachhai 2f2e7121eb Enable profiling from cli (#4437) 2020-08-20 23:22:39 +03:00
Saugat Pachhai de0fe94cd0 Make serializing utils use tree, misc changes (#4425)
* Also fixes plots not getting dumped on flow style
* Make serialization utils aware of trees
* Directly load yamls/tomls using utils, rather than using parser
2020-08-20 20:38:17 +03:00
Saugat Pachhai 5d1ad03fe9 git: do not ask for gpg signoff on merge (#4430) 2020-08-20 13:47:19 +03:00
Saugat Pachhai 9f1bd98fcc Mark test_new_simple as flaky (#4429)
It is for short period of time till it gets resolved
See: #4401
2020-08-20 10:37:58 +05:45
Ruslan Kuprieiev 41e6c22e7d dvc: move Dvc/RepoTree to dvc/tree (#4427)
This is where it really belongs.
2020-08-19 22:30:55 +03:00
Ruslan Kuprieiev b82104a608 dvc: bump to 1.6.0 2020-08-19 21:51:20 +03:00
Saugat Pachhai 14745039a4 Move to yaml1.2 (#4415)
* use yaml 1.2 everywhere

This commit:
* gets rid of pyyaml
* uses ruamel everywhere
* tries to unite toml and yaml

However, this will require a few more changes that will come soonish.
Eg: Current `parse_yaml` and `parse_toml` behavior could be error-prone,
so, it's better to introduce `tree` inside these serialization methods
(which I will try to tackle next).

Another is, yaml load can be slow for json. We could speed it up just by
using `json.load(s)` here (also in upcoming patch).

* Fix tests
2020-08-19 17:56:38 +03:00
Peter Rowlands (변기호) 958aa265e7 experiments: abstract executor output collection using trees (#4402)
* experiments: use RepoTree for executor output collection

* experiments: use tree.download for output collection

* cleanup docstring

* use string name for tqdm
2020-08-19 17:56:00 +03:00
Ruslan Kuprieiev ac24b5c6f0 dvc: introduce merge-driver (#4298)
Related to #4162
2020-08-19 17:55:12 +03:00
Peter Rowlands (변기호) 5b352491d4 git: handle rev_parse exception (#4422) 2020-08-19 21:21:16 +09:00
Ruslan Kuprieiev 32b5b33329 dvc: bump to 1.5.1 2020-08-19 00:10:06 +03:00
Peter Rowlands (변기호) 4aecb12988 experiments: fix flaky tests (#4414)
* experiments: lock exp workspace when using git

* remove timestamp from flaky exp show test
2020-08-18 19:58:30 +03:00
Paweł Redzyński ef944e6509 http: replace HEAD with streamed GET (#4390) 2020-08-18 12:45:06 +03:00
Saugat Pachhai e31364979b Move toml/yaml serialization to dvc.utils.serialize (#4411) 2020-08-18 13:01:16 +05:45
nik123 8cd4eeac18 status: check if target is missing both in cache and remote (#4398)
* status: check if target is missing both in cache and remote

* refactor: additional comments
2020-08-17 20:08:10 +03:00
Paweł Redzyński 6ae0be2179 metrics: show: debug info on parsing problems (#4370) 2020-08-17 17:37:15 +03:00
Peter Rowlands (변기호) 70ddb7c931 experiments: include experiment timestamps in dvc exp show output (#4389)
* experiments: include experiment commit times in show

* --no-timestamp can be used to hide timestamps

* update tests

* clean up kwargs
2020-08-17 17:35:43 +03:00
Saugat Pachhai c4bc4c83e8 upgrade to isort5 (#4399)
* Imports are no longer moved to top.
* `seed-isort-config` is no longer needed.
* Has a `black`-compatible profile/config.
* Also `isorts` the imports anywhere in the code
  (not just at the top).

Also bumps `restyled-isort` to newest version
2020-08-17 19:59:40 +05:45
Saugat Pachhai 31e9cc2e40 cleanup RepoTree (#4405)
1. Rename `_get_tree_pairs` to `_get_tree_pair`.
2. Add comments, docstrings and type-annotations.
3. Rename variable: `_discovered_subrepos` to `_subrepos_trie`.
4. Merge some nested ifs.
2020-08-17 19:42:45 +05:45
karajan1001 b8e40f5091 Solve check-ignore broken in a Linux pipeline (#4409)
fix #4407
1. Update test, now it can simulate a pipeline situation.
2. Solve remove `isatty` check in `dvc check-ignore --stdin` command.
2020-08-17 16:17:45 +03:00
Saugat Pachhai 1cfccef22e update flake8 to latest version (#4403) 2020-08-17 14:26:10 +05:45
nik123 44392941af ui: '-j' option now mentions docs (#4382)
* ui: '-j' option now mentions docs

* ui: copied '-j' default value desc. from cmd ref
2020-08-15 21:41:42 +03:00
Ruslan Kuprieiev 023dec4aa6 tree: make get_hash return type and hash pair (#4397)
Currently we kinda assume that whatever is returned by `get_file_hash`
is of type self.PARAM_CHECKSUM, which is not actually true. E.g. for
http it might return `etag` or `md5`, but we don't distinguish between
those and call both `etag`. This is becoming more relevant for dir
hashes that are computed a few different ways (e.g. in-memory md5 or
upload to remote and get etag for the dir file).

Prerequisite for #4144 and #3069
2020-08-15 20:35:39 +03:00
Saugat Pachhai b773ba73cf tests: add .dvcignore on the tests expectations (#4396)
Since #4229, we have started creating .dvcignore file after `dvc init`
This commit only adds that in assertions
2020-08-15 21:24:29 +05:45
Saugat Pachhai 0b1d4f10d6 RepoTree: add support for subrepo traversal (#4381)
Right now, no actual "functionality" is surfaced as
`subrepos` is always set to False by default for now.
2020-08-15 17:27:00 +03:00
Christoph Berganski 8b41655894 Derive WebDAVURLInfo from URLInfo instead of HTTPURLInfo (#4391)
Unused, empty http extra parts (params,query,fragment) cause problems
with pushing/pulling - lead to violation of the assumption where to look
for hash values in file/path names.

Context:
    https://discordapp.com/channels/485586884165107732/485596304961962003/743454659733094520
    https://discordapp.com/channels/485586884165107732/485596304961962003/743497749290287215
    https://discordapp.com/channels/485586884165107732/485596304961962003/743571921622401136
2020-08-14 19:46:03 +03:00
Paweł Redzyński 35f125f3df outputs: validate that output is not ignored (#4386) 2020-08-14 14:30:12 +03:00
Robert Lowry 00925029b4 exception text: link to troubleshooting page (#4339) 2020-08-13 13:09:58 +03:00
Paweł Redzyński 9c772f5f6c get-url: allow weak etags (#4372) 2020-08-13 12:03:47 +03:00
karajan1001 e3cfa7348c Result inconsistency of the dvcignore interfaces (#4378)
Fix #4333, as `is_ignored_file` and `is_ignored_dir` and `__call__`
share the same call `DvcIgnorePatterns.matches`, we only need to focus
on the out of repo situation.
1. Add tests for the `is_ignored_file` and `is_ignored_dir` for the out
of repo condition.
2. Now `is_ignored_file` and `is_ignored_dir` returns false for those
files outside the repo.
2020-08-13 01:22:03 +03:00
Peter Rowlands (변기호) 9b5740555c experiments: add options to include/exclude columns in exp show (#4375)
* experiments: add options to include/exclude specific metrics/params columns in
`dvc exp show`

* allow param/metric file to be provided to keep consistency with other
dvc commands

* match nested params/metrics names
2020-08-13 01:21:12 +03:00
Jorge OrpinelandRuslan Kuprieiev ce9205a3cc term: review "URL" term usage in remote cmds, and get/import-url (#4364)
* term: review upper case URL usage, and remote add url arg desc

* get/imp-url: better url arg help out

* Update dvc/command/get_url.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-08-13 01:12:45 +03:00
AnatolyandRuslan Kuprieiev 5cb36fc983 Special dir warning #3436 (#4229)
* add special folder warning

* add special folder warning

* remove special folder warning, start working on default dvcignore

* add default .dvcignote and templates

* remove dvcignore templates

* change text and add tests

* refactor

* dvcignore: ignore comments

* adjust tests

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-08-13 01:03:34 +03:00
Ruslan Kuprieiev 64f038fda8 dvc: bump to 1.5.0 2020-08-12 19:08:38 +03:00
Ruslan Kuprieiev a8f0c5241c dvc: migrate to azure-storage-blob >= 12.0 (#4379)
There has been some pretty drastic changes in 12.0, so migration
is a little bit more involved.

Fixes #3546
2020-08-12 19:07:28 +03:00
karajan1001 12c7013062 Remove dvcignore reset in repo._reset (#4371)
* Remove dvcignore reset in `repo._reset`

Repo `add`,`checkout`,etc will not change `.dvcignore`, and can remove dvcignore reset after these operations.

* Reset dvcignore manually after a subrepo created.

1. subrepo created during session open time.
2020-08-11 10:53:49 +03:00
Jorge OrpinelandRuslan Kuprieiev 9cb32b5d29 check-ignore: update help output (#4358)
* check_ignore: update help output
per https://github.com/iterative/dvc/pull/4282#pullrequestreview-460270636
and https://github.com/iterative/dvc/pull/4282#pullrequestreview-460272287

* check-ignore: more output string updates
per #4323 et al.

* check-ignore: min text update
to match iterative/dvc.org/pull/1673

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-08-11 10:38:23 +03:00
Saugat Pachhai 765d2ed61b Update pylint-pytest to >=0.3.0
Older versions had issues with loading pylint plugins on Windows.
Close #4357
2020-08-11 11:26:59 +05:45
Peter Rowlands (변기호) 78458ee5a1 push: do not push .dir file for directory with missing files (#4353)
* tests: add test to ensure dir hashes are not pushed for incomplete
directories

* push: do not push .dir file for directory with missing files

* update test
2020-08-10 15:09:00 +03:00
karajan1001 07dcf6953c Error message when no match found. (#4354)
* Error message when no match found.

1. remove error message when no match found.

* Test update.

1.Test update cover return 0 and 1

* Remove unused arguments

* Error message show in dvc check-ignore
2020-08-10 15:00:11 +03:00
Puneetha Pai 172032d3b3 Config to enable/disable updater (#4334)
* Updater: Add method to check dvc core config check_update

* Updater: check config before calling update._check

Fixes #1894
2020-08-10 11:59:25 +03:00
karajan1001 4e5a81402d Remove error message when exiting --stdin mode (#4368)
fix #4361
1. Remove error message when input a empty string
2. Remove error message when read input from stream
2. Remove error message when interrupt by `CTRL-C`
2020-08-10 11:33:56 +03:00
Peter Rowlands (변기호) fef635c6f0 pager: use --R/--RAW-CONTROL-CHARS with less (#4369) 2020-08-10 11:16:56 +03:00
Saugat Pachhai 8d1934d494 dvcignore: api to unhide subrepos directory (#4324)
* Allow ignoring subrepos in dvcignore

I implemented this inside dvcignore, and it
should not require "internal" APIs hack as we discussed before.

Right now, `ignore_subrepos` will only work for the paths of subrepo,
not anything inside it. And the API user is more or less only
RepoTree, so should be safe (we switch DvcTree and repo.tree based
on path prefixes, so this is only required during `walk`, so as it
shows the repo that were previously dvcignored and so that we could
switch the trees later during walk.

So, if dir is `dir -> repo`
So, `tree.walk("dir")` will return `[]`,
whereas `tree.walk("dir", ignore_subrepos=False)` will return `["repo"]`
But, successive walk will just return `("repo", [], [])`, and could
just be ignored as a side-effect.

* Fix tests
2020-08-10 09:58:16 +03:00
Ruslan Kuprieiev dea1950ba0 dvc: bump to 1.4.0 2020-08-07 04:31:02 +03:00
e2a5741374 Two new modes --all and --stdin for check-ignore (#4323)
* Two new mode `-all` and `--stdin` for check-ignore

Fix #4321

Introduce new arguments --all for dvc check-ignore
Add tests for dvc check-ignore --all

* `--stdin` and targets only one

1. `--stdin` and targets can and only can have one
2. more tests

* New tests for stdin mode and solve a problem

1. Add a new tests with mock stdin for interactive mode
2. Solve the problem of `assert "" in caplog.text` in tests always `True`.

* Remove path_input interface

1. remove path_input and make _ask to a public method instead

* Update dvc/command/check_ignore.py

* Update dvc/command/check_ignore.py

* Update dvc/command/check_ignore.py

* Update dvc/command/check_ignore.py

* Update dvc/command/check_ignore.py

* Update dvc/command/check_ignore.py

* Update dvc/command/check_ignore.py

* Update dvc/command/check_ignore.py

* fix styling

Co-authored-by: karajan1001 <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-08-06 22:30:28 +03:00
Peter Rowlands (변기호) ab23bcd2e8 experiments: support dvc repro --exp command line params (#4331)
* experiments: support passing params values instead of reading from user's workspace

* repro: add --params option for experiments

* support toml params

* update invalid param error message

* update tests
2020-08-07 00:04:02 +09:00
Sarthak khandelwalandRuslan Kuprieiev 4759517c8d repro: update status message for dvc-added files (#4317)
* stage/__init__.py: Updated output for dvc repro

* Delete config.ini

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-08-06 17:48:09 +03:00
Ruslan Kuprieiev 7afff1ef29 config: use wtree for non-repo configs (#4341)
For example, when `dvc import`ing something, dvc won't see stuff
declared in system and global configs, because GitTree doesn't see stuff
that is outside of the git repo.

Discord context:
https://discordapp.com/channels/485586884165107732/563406153334128681/740543492417126521
2020-08-06 17:37:21 +03:00
Peter Rowlands (변기호) d128d51a2b import: fix importing into subdirectory bug (#4340)
* tests: test importing into subdirectory

* import: fix bug when output subdir doesnt already exist

* use urlparse to determine if output path is local, not os.path.exists

* account for windows drive letters

* import: error out if output subdirectory does not exist
2020-08-06 23:26:52 +09:00
Casper da Costa-Luis afa0a30a73 deps: bump shtab (#4337)
Depends on https://github.com/iterative/shtab/pull/19
Fixes bash not completing filenames & dirs
2020-08-06 14:51:22 +03:00
Peter Rowlands (변기호) 6bb654750a experiments: add --no-pager option for exp show (#4345) 2020-08-06 15:48:40 +09:00
Saugat Pachhai 35dd1fda9c Fix version tests (#4329)
Tests had checks for python version number, expecting it to be only
single digits, which was failing for me on 3.6.10 and might fail
in the future with Python 3.10.

I adjusted it to expect minor and patch version to be of multiple digits.
2020-08-04 11:51:05 +05:45
Saugat Pachhai d8b03737ee Update updater.py (#4328) 2020-08-04 06:27:08 +03:00
Peter Rowlands (변기호) 73750df73f experiments: more intelligent checkout behavior (#4320)
* restore Experiments.chdir

* used in experiments.show

* experiments: make checkout work without `git reset --hard`

* experiments: remove --force parameter

* experiments: reject checkout if current workspace commit doesnt match
expected baseline
2020-08-04 04:44:51 +03:00
11d906ee51 dvc check-ignore command (#4282)
* Update some tests first

fix #3736

* first edition

* solve failure in Windows

* For Windows ci

* Some help ducuments issue.

* Update dvc/ignore.py

abspath

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

* Refactor with OutOfWorkSpaceError

* Solve a bug

* Update dvc/command/check_ignore.py

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

* Update dvc/command/check_ignore.py

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

* Update dvc/command/check_ignore.py

* Revert "Refactor with OutOfWorkSpaceError"

This reverts commit 27eec49ed67346c89157edea998b6b5fa81d88f5.

* Two change request

1. Argument `targets`'s description.
2. Error handling of `_get_normalize_path`

* Update dvc/main.py

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

* `check_ignore` now only accept one path a time

1. Add a new test for the out side repo cases
2. check ignore now only check one file not file lists

Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: karajan1001 <[email protected]>
Co-authored-by: Jorge Orpinel <[email protected]>
2020-08-03 08:27:50 +03:00
Ruslan Kuprieiev b823ce48e1 tests: fix version check on tags 2020-08-01 20:42:16 +03:00
Ruslan Kuprieiev 4061b74cf3 dvc: bump to 1.3.1 2020-08-01 20:18:09 +03:00
Ruslan Kuprieiev baa9a5701f tree: don't save hash twice (#4314)
We already do this in
https://github.com/iterative/dvc/blob/00de04c82d844b3bdf5162b2c78560f1b5656488/dvc/tree/base.py#L271
for both files and directories.
2020-07-31 23:44:44 +03:00
Arthur Colombini Gusmão 00de04c82d Fix typo in dvc remote modify output (#4311) 2020-07-31 22:04:25 +03:00
Ruslan KuprieievandSaugat Pachhai ed1c3a7a15 remote: allow local options for repo remotes (#4309)
* remote: allow local options for repo remotes

Fixes #4276

* Update tests/func/test_remote.py

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

* add more tests

Co-authored-by: Saugat Pachhai <[email protected]>
2020-07-31 09:30:07 +03:00
Ruslan Kuprieiev fcf2e3522c tree: get rid of explicit tree arguments (#4310)
* cache: don't make save() compute the hash

This is incorrect and leads to weird bugs like #4305

* tree: get rid of explicit tree arguments
2020-07-31 08:38:36 +03:00
Ruslan Kuprieiev dfdb24d89d dvc: bump to 1.3.0 2020-07-31 05:01:37 +03:00
Saugat Pachhai 99ffa7c6fd Use pytest>=6.0.1 (#4308)
pytest 6.0.0 had issue with `pylint`, complaining for `pytest.mark.*` being `not-callable`.
2020-07-30 19:41:42 +05:45
Ruslan Kuprieiev 9592246746 commit: make sure deps/outs exist (#4305)
Fixes #4266
2020-07-30 16:09:00 +03:00
Ruslan Kuprieiev 815444ffdc dvc: fix --quiet mode (#4304)
Part of the issue is a bug from #3806

Kudos to @karajan1001 for finding this bug while working on #4282
2020-07-30 06:04:41 +03:00
Christoph BerganskiandChristoph Berganski 2f48bb4089 tree,remote: add support for WebDAV (#4256)
* tree,remote: add support for webdav

Webdav support is based on https://pypi.org/project/webdavclient3/ and
supports basic download/upload operation, directory creation as well as
existence, file hash and isdir query. Copy, move and remove are also
implemented, though probably not used yet.

WebdavURLInfo is taken from https://github.com/shizacat/dvc/tree/remote-webdav

Fixes iterative#1153

* tree,remote: add further webdavclient3 options

Webdav token auth, certificate and key path and connection timeout are
configurable. Webdav username might be specified or extracted from URL.

Refs iterative#1153

* tree,remote: validate webdav client configuration

Refs iterative#1153

* tree,remote: WebDAV is written with capitalized 'DAV'...

Refs iterative#1153

* tree,remote: terminate WebDAV makedirs at self.path_info.path

This enables the WebDAV api location (e.g. '/public.php/webdav') to be
part of the remote 'url' configuration instead of beeing specified
separately via the 'root' option. The 'root' option may then be used to
specify real directories at the WebDAV storage, although using it to
set the api location is still possible.

Refs iterative#1153

* tree,remote: use >=3.14.5 for WebDAV dependency webdavclient3

Context: https://github.com/iterative/dvc/pull/4256#discussion_r458490609

Refs iterative#1153

* tree,remote: get rid of WebDAV 'root' option and add connection check

The WebDAV 'root' option was rather confusing and should be handled by
the initial 'path_info' from the config 'url' option.

Context: https://github.com/iterative/dvc/pull/4256#discussion_r458754500

While stripping the path/root from the hostname the port got lost, which
is fixed now by simply using the URLInfo 'replace' method as suggested.

Context: https://github.com/iterative/dvc/pull/4256#discussion_r458842853

The WebDAV client connection is tested by probing the existence of the
root (self.path_info.path).

Refs iterative#1153

* tree,remote: implement walk_files for WebDAV

Context: https://github.com/iterative/dvc/pull/4256#discussion_r458751391

Refs: iterative#1153

* tree,remote: let WebDAV client list query file info in walk_files

Context: https://github.com/iterative/dvc/pull/4256#discussion_r458751391

Refs iterative#1153

* tree,remote: add some unit tests for WebDAVTree

Refs iterative#1153

* tree,remote: use ConfigError for WebDAVTree and move Error to webdav.py

Context: https://github.com/iterative/dvc/pull/4256#discussion_r460591937

Refs iterative#1153

* tree,remote: remove/change some (unnecessary) comments

Context: https://github.com/iterative/dvc/pull/4256#discussion_r460592181

* tree,remote: uploading to WebDAV only create directories if not exist

* tree,remote: add BaseTree parameter use_dvcignore to WebDAVTree exists

Refs iterative/#1153

* tree,remote: remove WebDAVTree copy method as proposed

Context: https://github.com/iterative/dvc/pull/4256#discussion_r460892725

Refs iterative#1153

* tree,remote: add progress bar to WebDAV _download and _upload method

Context: https://github.com/iterative/dvc/pull/4256#discussion_r460593130

Refs iterative#1153

Co-authored-by: Christoph Berganski <[email protected]>
2020-07-29 23:58:41 +03:00
Peter Rowlands (변기호) c8d2f4f1fb experiments: support local parallel execution in temp directories (#4257)
* experiments: add initial local tmpdir executor

* fix detached head pull

* experiments: include unchanged (unrepro'd) stages in experiment hash

* experiments: repro single experiment using tmpdir executor by default

* stage experiments as stash commits before running

* include repro args/kwargs in stashed experiments

* support running arbitrary experiment commits (including stash commits)

* experiments: use ProcessPoolExecutor

* experiments: add `dvc exp` alias for `dvc experiments`

* experiments: add `dvc repro --experiment --queue`

* `--queue` can be used to stage an experiment for future execution

* experiments: show queued (unexecuted) experiments in `dvc exp show`

* revert ProcessPoolExecutor change

* experiments: add `dvc repro --run-all [--jobs]`

* `--run-all` can be used to run all queued experiments in parallel

* cleanup output

* experiments: use ProcessPoolExecutor

* fix returning unpicklable objects error

* update tests

* fix windows cleanup issue

* on windows tempdir cannot be removed if we are chdir'd into that
  directory
2020-07-29 14:51:06 +03:00
Saugat Pachhai 9ca3c05f6d Exclude pylint==6.0 (#4300)
https://github.com/pytest-dev/pytest/issues/7558
pylint throws error for `pytest.mark.*` functions in pytest6.

```
tests/unit/repo/test_repo.py:15:1: E1102: pytest.mark.parametrize is not callable (not-callable)
tests/unit/repo/test_repo.py:33:1: E1102: pytest.mark.parametrize is not callable (not-callable)
************* Module tests.unit.remote.ssh.test_connection
tests/unit/remote/ssh/test_connection.py:87:1: E1102: pytest.mark.skipif is not callable (not-callable)
tests/unit/remote/ssh/test_connection.py:100:1: E1102: pytest.mark.skipif is not callable (not-callable)
```
https://github.com/iterative/dvc/pull/4257#issuecomment-665474467
2020-07-29 15:13:30 +05:45
159 changed files with 5687 additions and 1828 deletions
+45 -2
View File
@@ -1,5 +1,48 @@
# Config for https://github.com/apps/release-drafter
branches:
- master
name-template: "$RESOLVED_VERSION 🦉 "
tag-template: "$RESOLVED_VERSION"
categories:
- title: "🚀 New Features and Enhancements "
labels:
- "feature"
- "enhancement"
- title: "💣 Breaking Changes "
labels:
- "breaking-change"
- title: "🏇 Optimizations "
labels:
- "optimize"
- "performance"
- title: "🐛 Bug Fixes "
labels:
- "bugfix"
- "bug"
- title: "🔨 Maintenance "
labels:
- "maintenance"
- "refactoring"
- "chore"
- "build"
- "ci"
change-template: "- $TITLE (#$NUMBER) @$AUTHOR"
version-resolver:
major:
labels:
- "major"
minor:
labels:
- "minor"
patch:
labels:
- "patch"
default: patch
exclude-labels:
- "skip-changelog"
template: |
> Refer to https://dvc.org/doc/install for installation instructions.
## Changes
$CHANGES
Thanks again to $CONTRIBUTORS for the contributions! 🎉
+2 -6
View File
@@ -4,15 +4,11 @@ repos:
language_version: python3
repo: https://github.com/ambv/black
rev: 19.10b0
- repo: https://github.com/asottile/seed-isort-config
rev: v2.1.1
hooks:
- id: seed-isort-config
- hooks:
- id: isort
language_version: python3
repo: https://github.com/timothycrosley/isort
rev: 4.3.21
rev: 5.4.2
- hooks:
- id: flake8
language_version: python3
@@ -22,7 +18,7 @@ repos:
- flake8-debugger
- flake8-string-format
repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9
rev: 3.8.3
- repo: local
hooks:
- id: pylint
+1 -1
View File
@@ -10,7 +10,7 @@ restylers:
interpreters:
- python
- name: isort
image: restyled/restyler-isort:v4.3.21
image: restyled/restyler-isort:v5.4.2
command:
- isort
arguments: []
+88 -6
View File
@@ -5,7 +5,12 @@ from copy import copy
from shortuuid import uuid
import dvc.prompt as prompt
from dvc.exceptions import CheckoutError, ConfirmRemoveError, DvcException
from dvc.exceptions import (
CheckoutError,
ConfirmRemoveError,
DvcException,
MergeError,
)
from dvc.path_info import WindowsPathInfo
from dvc.progress import Tqdm
from dvc.remote.slow_link_detection import slow_link_guard
@@ -128,7 +133,7 @@ class CloudCache:
return True
actual = self.tree.get_hash(path_info)
if hash_ != actual:
if hash_ != actual.value:
logger.debug(
"hash value '%s' for '%s' has changed (actual '%s').",
hash_,
@@ -276,8 +281,6 @@ class CloudCache:
self.tree.scheme,
)
if not hash_info:
hash_info = self.tree.save_info(path_info, tree=tree, **kwargs)
hash_ = hash_info[self.tree.PARAM_CHECKSUM]
return self._save(path_info, tree, hash_, save_link, **kwargs)
@@ -323,7 +326,7 @@ class CloudCache:
if not hash_ or not actual:
return True
if actual.split(".")[0] == hash_.split(".")[0]:
if actual.value.split(".")[0] == hash_.split(".")[0]:
# making cache file read-only so we don't need to check it
# next time
self.tree.protect(cache_info)
@@ -360,7 +363,7 @@ class CloudCache:
return self.changed_cache_file(hash_)
def already_cached(self, path_info):
current = self.tree.get_hash(path_info)
_, current = self.tree.get_hash(path_info)
if not current:
return False
@@ -552,3 +555,82 @@ class CloudCache:
filter_info.isin_or_eq(path_info / entry[self.tree.PARAM_CHECKSUM])
for entry in self.get_dir_cache(hash_)
)
def _to_dict(self, dir_info):
return {
entry[self.tree.PARAM_RELPATH]: entry[self.tree.PARAM_CHECKSUM]
for entry in dir_info
}
def _from_dict(self, dir_dict):
return [
{
self.tree.PARAM_RELPATH: relpath,
self.tree.PARAM_CHECKSUM: checksum,
}
for relpath, checksum in dir_dict.items()
]
@staticmethod
def _diff(ancestor, other, allow_removed=False):
from dictdiffer import diff
allowed = ["add"]
if allow_removed:
allowed.append("remove")
result = list(diff(ancestor, other))
for typ, _, _ in result:
if typ not in allowed:
raise MergeError(
"unable to auto-merge directories with diff that contains "
f"'{typ}'ed files"
)
return result
def _merge_dirs(self, ancestor_info, our_info, their_info):
from operator import itemgetter
from dictdiffer import patch
ancestor = self._to_dict(ancestor_info)
our = self._to_dict(our_info)
their = self._to_dict(their_info)
our_diff = self._diff(ancestor, our)
if not our_diff:
return self._from_dict(their)
their_diff = self._diff(ancestor, their)
if not their_diff:
return self._from_dict(our)
# make sure there are no conflicting files
self._diff(our, their, allow_removed=True)
merged = patch(our_diff + their_diff, ancestor, in_place=True)
# Sorting the list by path to ensure reproducibility
return sorted(
self._from_dict(merged), key=itemgetter(self.tree.PARAM_RELPATH)
)
def merge(self, ancestor_info, our_info, their_info):
assert our_info
assert their_info
if ancestor_info:
ancestor_hash = ancestor_info[self.tree.PARAM_CHECKSUM]
ancestor = self.get_dir_cache(ancestor_hash)
else:
ancestor = []
our_hash = our_info[self.tree.PARAM_CHECKSUM]
our = self.get_dir_cache(our_hash)
their_hash = their_info[self.tree.PARAM_CHECKSUM]
their = self.get_dir_cache(their_hash)
merged = self._merge_dirs(ancestor, our, their)
hash_info = self.tree.save_dir_info(merged)
return {hash_info.name: hash_info.value}
+68 -23
View File
@@ -91,12 +91,14 @@ class LocalCache(CloudCache):
def already_cached(self, path_info):
assert path_info.scheme in ["", "local"]
current_md5 = self.tree.get_hash(path_info)
current = self.tree.get_hash(path_info)
if not current_md5:
assert current.name == "md5"
if not current:
return False
return not self.changed_cache(current_md5)
return not self.changed_cache(current.value)
def _verify_link(self, path_info, link_type):
if link_type == "hardlink" and self.tree.getsize(path_info) == 0:
@@ -112,6 +114,7 @@ class LocalCache(CloudCache):
jobs=None,
show_checksums=False,
download=False,
log_missing=True,
):
# Return flattened dict containing all status info
dir_status, file_status, _ = self._status(
@@ -120,6 +123,7 @@ class LocalCache(CloudCache):
jobs=jobs,
show_checksums=show_checksums,
download=download,
log_missing=log_missing,
)
return dict(dir_status, **file_status)
@@ -130,6 +134,7 @@ class LocalCache(CloudCache):
jobs=None,
show_checksums=False,
download=False,
log_missing=True,
):
"""Return a tuple of (dir_status_info, file_status_info, dir_contents).
@@ -170,11 +175,20 @@ class LocalCache(CloudCache):
)
)
return self._make_status(
named_cache, show_checksums, local_exists, remote_exists
named_cache,
show_checksums,
local_exists,
remote_exists,
log_missing,
)
def _make_status(
self, named_cache, show_checksums, local_exists, remote_exists
self,
named_cache,
show_checksums,
local_exists,
remote_exists,
log_missing,
):
def make_names(hash_, names):
return {"name": hash_ if show_checksums else " ".join(names)}
@@ -197,7 +211,8 @@ class LocalCache(CloudCache):
self._fill_statuses(dir_status, local_exists, remote_exists)
self._fill_statuses(file_status, local_exists, remote_exists)
self._log_missing_caches(dict(dir_status, **file_status))
if log_missing:
self._log_missing_caches(dict(dir_status, **file_status))
return dir_status, file_status, dir_contents
@@ -255,6 +270,7 @@ class LocalCache(CloudCache):
path_infos = []
names = []
hashes = []
missing = []
for md5, info in Tqdm(
status_info.items(), desc="Analysing status", unit="file"
):
@@ -263,6 +279,8 @@ class LocalCache(CloudCache):
path_infos.append(remote.tree.hash_to_path_info(md5))
names.append(info["name"])
hashes.append(md5)
elif info["status"] == STATUS_MISSING:
missing.append(md5)
if download:
to_infos = cache
@@ -271,7 +289,7 @@ class LocalCache(CloudCache):
to_infos = path_infos
from_infos = cache
return from_infos, to_infos, names, hashes
return (from_infos, to_infos, names, hashes), missing
def _process(
self,
@@ -312,8 +330,10 @@ class LocalCache(CloudCache):
download=download,
)
dir_plans = self._get_plans(download, remote, dir_status, status)
file_plans = self._get_plans(download, remote, file_status, status)
dir_plans, _ = self._get_plans(download, remote, dir_status, status)
file_plans, missing_files = self._get_plans(
download, remote, file_status, status
)
total = len(dir_plans[0]) + len(file_plans[0])
if total == 0:
@@ -339,19 +359,32 @@ class LocalCache(CloudCache):
)
dir_futures = {}
for from_info, to_info, name, dir_hash in zip(*dir_plans):
wait_futures = {
future
for file_hash, future in file_futures.items()
if file_hash in dir_contents[dir_hash]
}
dir_futures[dir_hash] = executor.submit(
self._dir_upload,
func,
wait_futures,
from_info,
to_info,
name,
)
# if for some reason a file contained in this dir is
# missing both locally and in the remote, we want to
# push whatever file content we have, but should not
# push .dir file
for file_hash in missing_files:
if file_hash in dir_contents[dir_hash]:
logger.debug(
"directory '%s' contains missing files,"
"skipping .dir file upload",
name,
)
break
else:
wait_futures = {
future
for file_hash, future in file_futures.items()
if file_hash in dir_contents[dir_hash]
}
dir_futures[dir_hash] = executor.submit(
self._dir_upload,
func,
wait_futures,
from_info,
to_info,
name,
)
fails = sum(
future.result()
for future in concat(
@@ -404,7 +437,7 @@ class LocalCache(CloudCache):
@index_locked
def pull(self, named_cache, remote, jobs=None, show_checksums=False):
return self._process(
ret = self._process(
named_cache,
remote,
jobs=jobs,
@@ -412,6 +445,18 @@ class LocalCache(CloudCache):
download=True,
)
if not remote.tree.verify:
for checksum in named_cache.scheme_keys("local"):
cache_file = self.tree.hash_to_path_info(checksum)
if self.tree.exists(cache_file):
# We can safely save here, as existing corrupted files will
# be removed upon status, while files corrupted during
# download will not be moved from tmp_file
# (see `BaseTree.download()`)
self.tree.state.save(cache_file, checksum)
return ret
@staticmethod
def _log_missing_caches(hash_info_dict):
missing_caches = [
+10
View File
@@ -6,6 +6,7 @@ import sys
from .command import (
add,
cache,
check_ignore,
checkout,
commit,
completion,
@@ -79,6 +80,7 @@ COMMANDS = [
git_hook,
plots,
experiments,
check_ignore,
]
@@ -135,6 +137,14 @@ def get_parent_parser():
"""
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)
log_level_group = parent_parser.add_mutually_exclusive_group()
log_level_group.add_argument(
"-q", "--quiet", action="count", default=0, help="Be quiet."
+126
View File
@@ -0,0 +1,126 @@
import argparse
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 CmdCheckIgnore(CmdBase):
def __init__(self, args):
super().__init__(args)
self.ignore_filter = self.repo.tree.dvcignore
def _show_results(self, result):
if not result.match and not self.args.non_matching:
return
if self.args.details:
patterns = result.patterns
if not self.args.all:
patterns = patterns[-1:]
for pattern in patterns:
logger.info("{}\t{}".format(pattern, result.file))
else:
logger.info(result.file)
def _check_one_file(self, target):
result = self.ignore_filter.check_ignore(target)
self._show_results(result)
if result.match:
return 0
return 1
def _interactive_mode(self):
ret = 1
while True:
try:
target = input()
except (KeyboardInterrupt, EOFError):
break
if target == "":
break
if not self._check_one_file(target):
ret = 0
return ret
def _normal_mode(self):
ret = 1
for target in self.args.targets:
if not self._check_one_file(target):
ret = 0
return ret
def _check_args(self):
if not self.args.stdin and not self.args.targets:
raise DvcException("`targets` or `--stdin` needed")
if self.args.stdin and self.args.targets:
raise DvcException("cannot have both `targets` and `--stdin`")
if self.args.non_matching and not self.args.details:
raise DvcException(
"`--non-matching` is only valid with `--details`"
)
if self.args.all and not self.args.details:
raise DvcException("`--all` is only valid with `--details`")
if self.args.quiet and self.args.details:
raise DvcException("cannot use both `--details` and `--quiet`")
def run(self):
self._check_args()
if self.args.stdin:
return self._interactive_mode()
return self._normal_mode()
def add_parser(subparsers, parent_parser):
ADD_HELP = (
"Check whether files or directories are excluded due to `.dvcignore`."
)
parser = subparsers.add_parser(
"check-ignore",
parents=[parent_parser],
description=append_doc_link(ADD_HELP, "check-ignore"),
help=ADD_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-d",
"--details",
action="store_true",
default=False,
help="Show the exclude patterns along with each target path.",
)
parser.add_argument(
"-a",
"--all",
action="store_true",
default=False,
help="Include the target paths which don’t match any pattern "
"in the `--details` list.",
)
parser.add_argument(
"-n",
"--non-matching",
action="store_true",
default=False,
help="Include the target paths which don’t match any pattern "
"in the `--details` list.",
)
parser.add_argument(
"--stdin",
action="store_true",
default=False,
help="Read paths from standard input instead of providing `targets`.",
)
parser.add_argument(
"targets", nargs="*", help="File or directory paths to check",
).complete = completion.FILE
parser.set_defaults(func=CmdCheckIgnore)
+2 -1
View File
@@ -9,8 +9,9 @@ class CmdDaemonBase(CmdBaseNoRepo):
class CmdDaemonUpdater(CmdDaemonBase):
def run(self):
import os
from dvc.repo import Repo
from dvc.config import Config
from dvc.repo import Repo
from dvc.updater import Updater
root_dir = Repo.find_root()
+3 -1
View File
@@ -8,8 +8,8 @@ logger = logging.getLogger(__name__)
def _show_ascii(G):
from dvc.repo.graph import get_pipelines
from dvc.dagascii import draw
from dvc.repo.graph import get_pipelines
pipelines = get_pipelines(G)
@@ -22,6 +22,7 @@ def _show_ascii(G):
def _show_dot(G):
import io
from networkx.drawing.nx_pydot import write_dot
dot_file = io.StringIO()
@@ -31,6 +32,7 @@ def _show_dot(G):
def _build(G, target=None, full=False):
import networkx as nx
from dvc.repo.graph import get_pipeline, get_pipelines
if target:
+5 -1
View File
@@ -97,7 +97,11 @@ def shared_parent_parser():
"-j",
"--jobs",
type=int,
help="Number of jobs to run simultaneously.",
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
parent_parser.add_argument(
+1 -1
View File
@@ -25,7 +25,7 @@ class CmdDestroy(CmdBase):
)
self.repo.destroy()
except Exception: # noqa, pylint: disable=broad-except
except DvcException:
logger.exception("failed to destroy DVC")
return 1
return 0
+23 -15
View File
@@ -11,19 +11,30 @@ from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
def _show_md(diff):
def _digest(checksum):
if isinstance(checksum, str):
return checksum[0:8]
return "{}..{}".format(checksum["old"][0:8], checksum["new"][0:8])
def _show_md(diff, show_hash=False):
from dvc.utils.diff import table
header = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
rows = []
for status in ["added", "deleted", "modified"]:
entries = diff.get(status, [])
if not entries:
continue
paths = sorted(entry["path"] for entry in entries)
for path in paths:
rows.append([status, path])
for entry in entries:
path = entry["path"]
if show_hash:
check_sum = _digest(entry.get("hash", ""))
rows.append([status, check_sum, path])
else:
rows.append([status, path])
return table(["Status", "Path"], rows, True)
return table(header, rows, True)
class CmdDiff(CmdBase):
@@ -33,8 +44,7 @@ class CmdDiff(CmdBase):
Given a diff structure, generate a string of paths separated
by new lines and grouped together by their state.
A group's header is colored and its entries are sorted to enhance
readability, for example:
A group's header is colored to enhance readability, for example:
Added:
another_file.txt
@@ -55,11 +65,6 @@ class CmdDiff(CmdBase):
At the bottom, include a summary with the number of files per state.
"""
def _digest(checksum):
if isinstance(checksum, str):
return checksum[0:8]
return "{}..{}".format(checksum["old"][0:8], checksum["new"][0:8])
colors = {
"added": colorama.Fore.GREEN,
"modified": colorama.Fore.YELLOW,
@@ -110,16 +115,19 @@ class CmdDiff(CmdBase):
def run(self):
try:
diff = self.repo.diff(self.args.a_rev, self.args.b_rev)
show_hash = self.args.show_hash
if not self.args.show_hash:
for _, entries in diff.items():
for key, entries in diff.items():
entries = sorted(entries, key=lambda entry: entry["path"])
if not show_hash:
for entry in entries:
del entry["hash"]
diff[key] = entries
if self.args.show_json:
logger.info(json.dumps(diff))
elif self.args.show_md:
logger.info(_show_md(diff))
logger.info(_show_md(diff, show_hash))
elif diff:
logger.info(self._format(diff))
+214 -61
View File
@@ -2,26 +2,69 @@ import argparse
import io
import logging
from collections import OrderedDict
from datetime import date
from itertools import groupby
from typing import Iterable, Optional
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.command.metrics import DEFAULT_PRECISION
from dvc.exceptions import DvcException
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.utils.flatten import flatten
logger = logging.getLogger(__name__)
def _update_names(names, items):
from flatten_json import flatten
def _filter_names(
names: Iterable,
label: str,
include: Optional[Iterable],
exclude: Optional[Iterable],
):
if include and exclude:
intersection = set(include) & set(exclude)
if intersection:
values = ", ".join(intersection)
raise InvalidArgumentError(
f"'{values}' specified in both --include-{label} and"
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(matches)
if include:
ret = set()
_filter(include, ret.update)
else:
ret = set(names)
if exclude:
_filter(exclude, ret.difference_update)
return {".".join(name) for name in ret}
def _update_names(names, items):
for name, item in items:
if isinstance(item, dict):
item = flatten(item, ".")
item = flatten(item)
names.update(item.keys())
else:
names.add(name)
def _collect_names(all_experiments):
def _collect_names(all_experiments, **kwargs):
metric_names = set()
param_names = set()
@@ -30,62 +73,125 @@ def _collect_names(all_experiments):
_update_names(metric_names, exp.get("metrics", {}).items())
_update_names(param_names, exp.get("params", {}).items())
metric_names = _filter_names(
metric_names,
"metrics",
kwargs.get("include_metrics"),
kwargs.get("exclude_metrics"),
)
param_names = _filter_names(
param_names,
"params",
kwargs.get("include_params"),
kwargs.get("exclude_params"),
)
return sorted(metric_names), sorted(param_names)
def _collect_rows(
base_rev, experiments, metric_names, param_names, precision=None
base_rev,
experiments,
metric_names,
param_names,
precision=DEFAULT_PRECISION,
no_timestamp=False,
):
from flatten_json import flatten
for i, (rev, exp) in enumerate(experiments.items()):
row = []
style = None
queued = "*" if exp.get("queued", False) else ""
if precision is None:
precision = DEFAULT_PRECISION
if rev == "baseline":
name = exp.get("name", base_rev)
row.append(f"{name}")
style = "bold"
elif i < len(experiments) - 1:
row.append(f"├── {queued}{rev[:7]}")
else:
row.append(f"└── {queued}{rev[:7]}")
if not no_timestamp:
row.append(_format_time(exp.get("timestamp")))
_extend_row(
row, metric_names, exp.get("metrics", {}).items(), precision
)
_extend_row(row, param_names, exp.get("params", {}).items(), precision)
yield row, style
def _format_time(timestamp):
if timestamp is None:
return "-"
if timestamp.date() == date.today():
fmt = "%I:%M %p"
else:
fmt = "%b %d, %Y"
return timestamp.strftime(fmt)
def _extend_row(row, names, items, precision):
def _round(val):
if isinstance(val, float):
return round(val, precision)
return val
def _extend(row, names, items):
for fname, item in items:
if isinstance(item, dict):
item = flatten(item, ".")
else:
item = {fname: item}
for name in names:
if name in item:
row.append(str(_round(item[name])))
else:
row.append("-")
if not items:
row.extend(["-"] * len(names))
return
for i, (rev, exp) in enumerate(experiments.items()):
row = []
style = None
if rev == "baseline":
row.append(f"{base_rev}")
style = "bold"
elif i < len(experiments) - 1:
row.append(f"├── {rev[:7]}")
for fname, item in items:
if isinstance(item, dict):
item = flatten(item)
else:
row.append(f"└── {rev[:7]}")
_extend(row, metric_names, exp.get("metrics", {}).items())
_extend(row, param_names, exp.get("params", {}).items())
yield row, style
item = {fname: item}
for name in names:
if name in item:
value = item[name]
text = str(_round(value)) if value is not None else "-"
row.append(text)
else:
row.append("-")
def _show_experiments(all_experiments, console, precision=None):
def _parse_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(","))
return ret
def _show_experiments(all_experiments, console, **kwargs):
from rich.table import Table
from dvc.scm.git import Git
metric_names, param_names = _collect_names(all_experiments)
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", []))
metric_names, param_names = _collect_names(
all_experiments,
include_metrics=include_metrics,
exclude_metrics=exclude_metrics,
include_params=include_params,
exclude_params=exclude_params,
)
table = Table()
table.add_column("Experiment")
table.add_column("Experiment", no_wrap=True)
if not kwargs.get("no_timestamp", False):
table.add_column("Created")
for name in metric_names:
table.add_column(name, justify="right")
table.add_column(name, justify="right", no_wrap=True)
for name in param_names:
table.add_column(name, justify="left")
@@ -94,11 +200,7 @@ def _show_experiments(all_experiments, console, precision=None):
base_rev = base_rev[:7]
for row, _, in _collect_rows(
base_rev,
experiments,
metric_names,
param_names,
precision=precision,
base_rev, experiments, metric_names, param_names, **kwargs,
):
table.add_row(*row)
@@ -108,6 +210,7 @@ def _show_experiments(all_experiments, console, precision=None):
class CmdExperimentsShow(CmdBase):
def run(self):
from rich.console import Console
from dvc.utils.pager import pager
if not self.repo.experiments:
@@ -118,17 +221,30 @@ class CmdExperimentsShow(CmdBase):
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
sha_only=self.args.sha,
)
# 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.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
)
_show_experiments(
all_experiments,
console,
include_metrics=self.args.include_metrics,
exclude_metrics=self.args.exclude_metrics,
include_params=self.args.include_params,
exclude_params=self.args.exclude_params,
no_timestamp=self.args.no_timestamp,
)
_show_experiments(all_experiments, console)
pager(console.file.getvalue())
if not self.args.no_pager:
pager(console.file.getvalue())
except DvcException:
logger.exception("failed to show experiments")
return 1
@@ -141,9 +257,7 @@ class CmdExperimentsCheckout(CmdBase):
if not self.repo.experiments:
return 0
self.repo.experiments.checkout(
self.args.experiment, force=self.args.force
)
self.repo.experiments.checkout(self.args.experiment)
return 0
@@ -229,6 +343,7 @@ def add_parser(subparsers, parent_parser):
experiments_parser = subparsers.add_parser(
"experiments",
parents=[parent_parser],
aliases=["exp"],
description=append_doc_link(EXPERIMENTS_HELP, "experiments"),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
@@ -269,6 +384,52 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Show metrics for all commits.",
)
experiments_show_parser.add_argument(
"--no-pager",
action="store_true",
default=False,
help="Do not pipe output into a pager.",
)
experiments_show_parser.add_argument(
"--include-metrics",
action="append",
default=[],
help="Include the specified metrics in output table.",
metavar="<metrics_list>",
)
experiments_show_parser.add_argument(
"--exclude-metrics",
action="append",
default=[],
help="Exclude the specified metrics from output table.",
metavar="<metrics_list>",
)
experiments_show_parser.add_argument(
"--include-params",
action="append",
default=[],
help="Include the specified params in output table.",
metavar="<params_list>",
)
experiments_show_parser.add_argument(
"--exclude-params",
action="append",
default=[],
help="Exclude the specified params from output table.",
metavar="<params_list>",
)
experiments_show_parser.add_argument(
"--no-timestamp",
action="store_true",
default=False,
help="Do not show experiment timestamps.",
)
experiments_show_parser.add_argument(
"--sha",
action="store_true",
default=False,
help="Always show git commit SHAs instead of branch/tag names.",
)
experiments_show_parser.set_defaults(func=CmdExperimentsShow)
EXPERIMENTS_CHECKOUT_HELP = "Checkout experiments."
@@ -281,14 +442,6 @@ def add_parser(subparsers, parent_parser):
help=EXPERIMENTS_CHECKOUT_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_checkout_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Overwrite your current workspace with changes from the "
"experiment.",
)
experiments_checkout_parser.add_argument(
"experiment", help="Checkout this experiment.",
)
+5 -1
View File
@@ -123,7 +123,11 @@ def add_parser(subparsers, parent_parser):
"-j",
"--jobs",
type=int,
help="Number of jobs to run simultaneously.",
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
gc_parser.add_argument(
+44
View File
@@ -61,6 +61,26 @@ class CmdPrePush(CmdHookBase):
return main(["push"])
class CmdMergeDriver(CmdHookBase):
def _run(self):
from dvc.dvcfile import Dvcfile
from dvc.repo import Repo
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)
our.merge(ancestor, their)
return 0
finally:
dvc.close()
def add_parser(subparsers, parent_parser):
GIT_HOOK_HELP = "Run GIT hook."
@@ -113,3 +133,27 @@ def add_parser(subparsers, parent_parser):
"args", nargs="*", help="Arguments passed by GIT or pre-commit tool.",
)
pre_push_parser.set_defaults(func=CmdPrePush)
MERGE_DRIVER_HELP = "Run GIT merge driver."
merge_driver_parser = git_hook_subparsers.add_parser(
"merge-driver",
parents=[parent_parser],
description=MERGE_DRIVER_HELP,
help=MERGE_DRIVER_HELP,
)
merge_driver_parser.add_argument(
"--ancestor",
required=True,
help="Ancestor's version of the conflicting file.",
)
merge_driver_parser.add_argument(
"--our",
required=True,
help="Current version of the conflicting file.",
)
merge_driver_parser.add_argument(
"--their",
required=True,
help="Other branch's version of the conflicting file.",
)
merge_driver_parser.set_defaults(func=CmdMergeDriver)
+8 -12
View File
@@ -42,20 +42,16 @@ def add_parser(subparsers, parent_parser):
)
import_parser.add_argument(
"url",
help="Supported urls:\n"
"/path/to/file\n"
"/path/to/directory\n"
"C:\\\\path\\to\\file\n"
"C:\\\\path\\to\\directory\n"
help="Location of the data to download. Supported URLs:\n"
"/absolute/path/to/file/or/dir\n"
"relative/path/to/file/or/dir\n"
"C:\\\\path\\to\\file\\or\\dir\n"
"https://example.com/path/to/file\n"
"s3://bucket/path/to/file\n"
"s3://bucket/path/to/directory\n"
"gs://bucket/path/to/file\n"
"gs://bucket/path/to/directory\n"
"s3://bucket/key/path\n"
"gs://bucket/path/to/file/or/dir\n"
"hdfs://example.com/path/to/file\n"
"ssh://example.com:/path/to/file\n"
"ssh://example.com:/path/to/directory\n"
"remote://myremote/path/to/file (see `dvc remote`)",
"ssh://example.com/absolute/path/to/file/or/dir\n"
"remote://remote_name/path/to/file/or/dir (see `dvc remote`)",
)
import_parser.add_argument(
"out", nargs="?", help="Destination path to put files to.",
+1 -1
View File
@@ -8,8 +8,8 @@ logger = logging.getLogger(__name__)
class CmdInit(CmdBaseNoRepo):
def run(self):
from dvc.repo import Repo
from dvc.exceptions import InitError
from dvc.repo import Repo
try:
self.repo = Repo.init(
+2 -1
View File
@@ -2,6 +2,7 @@ import argparse
import logging
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
@@ -10,7 +11,7 @@ class CmdInstall(CmdBase):
def run(self):
try:
self.repo.install(self.args.use_pre_commit_tool)
except Exception: # noqa, pylint:disable=broad-except
except DvcException:
logger.exception("failed to install DVC Git hooks")
return 1
return 0
+2 -2
View File
@@ -14,8 +14,8 @@ DEFAULT_PRECISION = 5
def show_metrics(
metrics, all_branches=False, all_tags=False, all_commits=False
):
from flatten_json import flatten
from dvc.utils.diff import format_dict
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.
@@ -31,7 +31,7 @@ def show_metrics(
continue
logger.info(f"\t{fname}:")
for key, value in flatten(format_dict(metric), ".").items():
for key, value in flatten(format_dict(metric)).items():
logger.info(f"\t\t{key}: {value}")
if missing:
+13 -1
View File
@@ -86,7 +86,12 @@ class CmdPlotsShow(CmdPlots):
class CmdPlotsDiff(CmdPlots):
def _func(self, *args, **kwargs):
return self.repo.plots.diff(*args, revs=self.args.revisions, **kwargs)
return self.repo.plots.diff(
*args,
revs=self.args.revisions,
experiment=self.args.experiment,
**kwargs,
)
class CmdPlotsModify(CmdPlots):
@@ -151,6 +156,13 @@ def add_parser(subparsers, parent_parser):
help="Plots file to visualize. Shows all plots by default.",
metavar="<path>",
).complete = completion.FILE
plots_diff_parser.add_argument(
"-e",
"--experiment",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
plots_diff_parser.add_argument(
"revisions", nargs="*", default=None, help="Git commits to plot from",
)
+11 -6
View File
@@ -3,7 +3,7 @@ import logging
from dvc.command.base import append_doc_link, fix_subparsers
from dvc.command.config import CmdConfig
from dvc.config import ConfigError
from dvc.config import ConfigError, merge
from dvc.utils import format_link
logger = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ class CmdRemote(CmdConfig):
def _check_exists(self, conf):
if self.args.name not in conf["remote"]:
raise ConfigError(f"remote '{self.args.name}' doesn't exists.")
raise ConfigError(f"remote '{self.args.name}' doesn't exist.")
class CmdRemoteAdd(CmdRemote):
@@ -61,7 +61,12 @@ class CmdRemoteRemove(CmdRemote):
class CmdRemoteModify(CmdRemote):
def run(self):
with self.config.edit(self.args.level) as conf:
self._check_exists(conf)
merged = self.config.load_config_to_level(self.args.level)
merge(merged, conf)
self._check_exists(merged)
if self.args.name not in conf["remote"]:
conf["remote"][self.args.name] = {}
section = conf["remote"][self.args.name]
if self.args.unset:
section.pop(self.args.option, None)
@@ -167,7 +172,7 @@ def add_parser(subparsers, parent_parser):
remote_add_parser.add_argument("name", help="Name of the remote")
remote_add_parser.add_argument(
"url",
help="URL. See full list of supported urls at {}".format(
help="Remote location. See full list of supported URLs at {}".format(
format_link("https://man.dvc.org/remote")
),
)
@@ -196,7 +201,7 @@ def add_parser(subparsers, parent_parser):
formatter_class=argparse.RawDescriptionHelpFormatter,
)
remote_default_parser.add_argument(
"name", nargs="?", help="Name of the remote."
"name", nargs="?", help="Name of the remote"
)
remote_default_parser.add_argument(
"-u",
@@ -215,7 +220,7 @@ def add_parser(subparsers, parent_parser):
help=REMOTE_MODIFY_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
remote_modify_parser.add_argument("name", help="Name of the remote.")
remote_modify_parser.add_argument("name", help="Name of the remote")
remote_modify_parser.add_argument(
"option", help="Name of the option to modify."
)
+23
View File
@@ -41,6 +41,10 @@ class CmdRepro(CmdBase):
recursive=self.args.recursive,
force_downstream=self.args.force_downstream,
experiment=self.args.experiment,
queue=self.args.queue,
run_all=self.args.run_all,
jobs=self.args.jobs,
params=self.args.params,
)
if len(stages) == 0:
@@ -174,4 +178,23 @@ def add_parser(subparsers, parent_parser):
default=False,
help=argparse.SUPPRESS,
)
repro_parser.add_argument(
"--params",
action="append",
default=[],
help="Declare parameter values for an experiment.",
metavar="[<filename>:]<params_list>",
)
repro_parser.add_argument(
"--queue", action="store_true", default=False, help=argparse.SUPPRESS
)
repro_parser.add_argument(
"--run-all",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
repro_parser.add_argument(
"-j", "--jobs", type=int, help=argparse.SUPPRESS, metavar="<number>"
)
repro_parser.set_defaults(func=CmdRepro)
+2 -1
View File
@@ -1,6 +1,7 @@
import logging
from dvc.command.data_sync import CmdDataBase
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
@@ -63,7 +64,7 @@ class CmdDataStatus(CmdDataBase):
else:
logger.info(self.UP_TO_DATE_MSG)
except Exception: # noqa, pylint: disable=broad-except
except DvcException:
logger.exception("failed to obtain data status")
return 1
return 0
+30 -10
View File
@@ -119,6 +119,16 @@ HTTP_COMMON = {
"password": str,
"ask_password": Bool,
}
WEBDAV_COMMON = {
"user": str,
"password": str,
"ask_password": Bool,
"token": str,
"cert_path": str,
"key_path": str,
"timeout": Coerce(int),
}
SCHEMA = {
"core": {
"remote": Lower,
@@ -128,6 +138,7 @@ SCHEMA = {
Optional("hardlink_lock", default=False): Bool,
Optional("no_scm", default=False): Bool,
Optional("experiments", default=False): Bool,
Optional("check_update", default=True): Bool,
},
"cache": {
"local": str,
@@ -151,7 +162,7 @@ SCHEMA = {
"endpointurl": str,
"access_key_id": str,
"secret_access_key": str,
Optional("listobjects", default=False): Bool,
Optional("listobjects", default=False): Bool, # obsoleted
Optional("use_ssl", default=True): Bool,
"sse": str,
"sse_kms_key_id": str,
@@ -199,6 +210,8 @@ SCHEMA = {
},
"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
}
)
@@ -258,7 +271,7 @@ class Config(dict):
@classmethod
def get_dir(cls, level):
from appdirs import user_config_dir, site_config_dir
from appdirs import site_config_dir, user_config_dir
assert level in ("global", "system")
@@ -312,11 +325,17 @@ 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
# the repo.
return self.tree if level == "repo" else self.wtree
def _load_config(self, level):
filename = self.files[level]
tree = self._get_tree(level)
if self.tree.exists(filename, use_dvcignore=False):
with self.tree.open(filename) as fobj:
if tree.exists(filename, use_dvcignore=False):
with tree.open(filename) as fobj:
conf_obj = configobj.ConfigObj(fobj)
else:
conf_obj = configobj.ConfigObj()
@@ -324,13 +343,14 @@ class Config(dict):
def _save_config(self, level, conf_dict):
filename = self.files[level]
tree = self._get_tree(level)
logger.debug(f"Writing '{filename}'.")
self.tree.makedirs(os.path.dirname(filename))
tree.makedirs(os.path.dirname(filename))
config = configobj.ConfigObj(_pack_remotes(conf_dict))
with self.tree.open(filename, "wb") as fobj:
with tree.open(filename, "wb") as fobj:
config.write(fobj)
config.filename = filename
@@ -388,7 +408,7 @@ class Config(dict):
if merge_level == level:
break
if merge_level in self.files:
_merge(merged_conf, self.load_one(merge_level))
merge(merged_conf, self.load_one(merge_level))
return merged_conf
@contextmanager
@@ -402,7 +422,7 @@ class Config(dict):
conf = self._save_paths(conf, self.files[level])
merged_conf = self.load_config_to_level(level)
_merge(merged_conf, conf)
merge(merged_conf, conf)
self.validate(merged_conf)
self._save_config(level, conf)
@@ -441,11 +461,11 @@ def _pack_remotes(conf):
return result
def _merge(into, update):
def merge(into, update):
"""Merges second dict into first recursively"""
for key, val in update.items():
if isinstance(into.get(key), dict) and isinstance(val, dict):
_merge(into[key], val)
merge(into[key], val)
else:
into[key] = val
+1 -1
View File
@@ -25,7 +25,7 @@ def _popen(cmd, **kwargs):
def _spawn_windows(cmd, env):
from subprocess import STARTUPINFO, STARTF_USESHOWWINDOW
from subprocess import STARTF_USESHOWWINDOW, STARTUPINFO
creationflags = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
+16 -18
View File
@@ -81,26 +81,18 @@ class DataCloud:
"""
remote = self.get_remote(remote, "pull")
downloaded_items_num = self.repo.cache.local.pull(
return self.repo.cache.local.pull(
cache, jobs=jobs, remote=remote, show_checksums=show_checksums
)
if not remote.tree.verify:
self._save_pulled_checksums(cache)
return downloaded_items_num
def _save_pulled_checksums(self, cache):
for checksum in cache.scheme_keys("local"):
cache_file = self.repo.cache.local.tree.hash_to_path_info(checksum)
if self.repo.cache.local.tree.exists(cache_file):
# We can safely save here, as existing corrupted files will
# be removed upon status, while files corrupted during
# download will not be moved from tmp_file
# (see `RemoteBASE.download()`)
self.repo.state.save(cache_file, checksum)
def status(self, cache, jobs=None, remote=None, show_checksums=False):
def status(
self,
cache,
jobs=None,
remote=None,
show_checksums=False,
log_missing=True,
):
"""Check status of data items in a cloud-agnostic way.
Args:
@@ -111,8 +103,14 @@ class DataCloud:
is used.
show_checksums (bool): show checksums instead of file names in
information messages.
log_missing (bool): log warning messages if file doesn't exist
neither in cache, neither in cloud.
"""
remote = self.get_remote(remote, "status")
return self.repo.cache.local.status(
cache, jobs=jobs, remote=remote, show_checksums=show_checksums
cache,
jobs=jobs,
remote=remote,
show_checksums=show_checksums,
log_missing=log_missing,
)
+9 -13
View File
@@ -2,12 +2,11 @@ import os
from collections import defaultdict
import dpath.util
import toml
import yaml
from voluptuous import Any
from dvc.dependency.local import LocalDependency
from dvc.exceptions import DvcException
from dvc.utils.serialize import LOADERS, ParseError
class MissingParamsError(DvcException):
@@ -22,8 +21,6 @@ class ParamsDependency(LocalDependency):
PARAM_PARAMS = "params"
PARAM_SCHEMA = {PARAM_PARAMS: Any(dict, list, None)}
DEFAULT_PARAMS_FILE = "params.yaml"
PARAMS_FILE_LOADERS = defaultdict(lambda: yaml.safe_load)
PARAMS_FILE_LOADERS.update({".toml": toml.load})
def __init__(self, stage, path, params):
info = {}
@@ -88,15 +85,14 @@ class ParamsDependency(LocalDependency):
if not self.exists:
return {}
with self.repo.tree.open(self.path_info, "r") as fobj:
try:
config = self.PARAMS_FILE_LOADERS[
self.path_info.suffix.lower()
](fobj)
except (yaml.YAMLError, toml.TomlDecodeError) as exc:
raise BadParamFileError(
f"Unable to read parameters from '{self}'"
) from exc
suffix = self.path_info.suffix.lower()
loader = LOADERS[suffix]
try:
config = loader(self.path_info, tree=self.repo.tree)
except ParseError as exc:
raise BadParamFileError(
f"Unable to read parameters from '{self}'"
) from exc
ret = {}
for param in self.params:
+8 -3
View File
@@ -50,7 +50,7 @@ class RepoDependency(LocalDependency):
return external_repo(d["url"], rev=rev)
def _get_checksum(self, locked=True):
from dvc.repo.tree import RepoTree
from dvc.tree.repo import RepoTree
with self._make_repo(locked=locked) as repo:
try:
@@ -64,10 +64,12 @@ class RepoDependency(LocalDependency):
# We are polluting our repo cache with some dir listing here
if tree.isdir(path):
return self.repo.cache.local.tree.get_hash(path, tree=tree)
return self.repo.cache.local.tree.get_hash(
path, tree=tree
).value
return tree.get_file_hash(path)
def status(self):
def workspace_status(self):
current_checksum = self._get_checksum(locked=True)
updated_checksum = self._get_checksum(locked=False)
@@ -76,6 +78,9 @@ class RepoDependency(LocalDependency):
return {}
def status(self):
return self.workspace_status()
def save(self):
pass
+57 -36
View File
@@ -16,7 +16,13 @@ from dvc.stage.exceptions import (
from dvc.stage.loader import SingleStageLoader, StageLoader
from dvc.utils import relpath
from dvc.utils.collections import apply_diff
from dvc.utils.yaml import dump_yaml, parse_yaml, parse_yaml_for_update
from dvc.utils.serialize import (
dump_yaml,
load_yaml,
modify_yaml,
parse_yaml,
parse_yaml_for_update,
)
logger = logging.getLogger(__name__)
@@ -56,9 +62,10 @@ def check_dvc_filename(path):
class FileMixin:
SCHEMA = None
def __init__(self, repo, path, **kwargs):
def __init__(self, repo, path, verify=True, **kwargs):
self.repo = repo
self.path = path
self.verify = verify
def __repr__(self):
return "{}: {}".format(
@@ -90,7 +97,8 @@ class FileMixin:
# 3. path doesn't represent a regular file
if not self.exists():
raise StageFileDoesNotExistError(self.path)
check_dvc_filename(self.path)
if self.verify:
check_dvc_filename(self.path)
if not self.repo.tree.isfile(self.path):
raise StageFileIsNotDvcFileError(self.path)
@@ -115,6 +123,9 @@ class FileMixin:
def dump(self, stage, **kwargs):
raise NotImplementedError
def merge(self, ancestor, other):
raise NotImplementedError
class SingleStageFile(FileMixin):
from dvc.schema import COMPILED_SINGLE_STAGE_SCHEMA as SCHEMA
@@ -134,7 +145,8 @@ class SingleStageFile(FileMixin):
from dvc.stage import PipelineStage
assert not isinstance(stage, PipelineStage)
check_dvc_filename(self.path)
if self.verify:
check_dvc_filename(self.path)
logger.debug(
"Saving information to '{file}'.".format(file=relpath(self.path))
)
@@ -144,6 +156,14 @@ class SingleStageFile(FileMixin):
def remove_stage(self, stage): # pylint: disable=unused-argument
self.remove()
def merge(self, ancestor, other):
assert isinstance(ancestor, SingleStageFile)
assert isinstance(other, SingleStageFile)
stage = self.stage
stage.merge(ancestor.stage, other.stage)
self.dump(stage)
class PipelineFile(FileMixin):
"""Abstraction for pipelines file, .yaml + .lock combined."""
@@ -161,7 +181,8 @@ class PipelineFile(FileMixin):
from dvc.stage import PipelineStage
assert isinstance(stage, PipelineStage)
check_dvc_filename(self.path)
if self.verify:
check_dvc_filename(self.path)
if update_pipeline and not stage.is_data_source:
self._dump_pipeline_file(stage)
@@ -173,30 +194,27 @@ class PipelineFile(FileMixin):
self._lockfile.dump(stage)
def _dump_pipeline_file(self, stage):
data = {}
if self.exists():
with open(self.path) as fd:
data = parse_yaml_for_update(fd.read(), self.path)
else:
logger.info("Creating '%s'", self.relpath)
open(self.path, "w+").close()
data["stages"] = data.get("stages", {})
stage_data = serialize.to_pipeline_file(stage)
existing_entry = stage.name in data["stages"]
action = "Modifying" if existing_entry else "Adding"
logger.info("%s stage '%s' in '%s'", action, stage.name, self.relpath)
with modify_yaml(self.path, tree=self.repo.tree) as data:
if not data:
logger.info("Creating '%s'", self.relpath)
if existing_entry:
orig_stage_data = data["stages"][stage.name]
if "meta" in orig_stage_data:
stage_data[stage.name]["meta"] = orig_stage_data["meta"]
apply_diff(stage_data[stage.name], orig_stage_data)
else:
data["stages"].update(stage_data)
data["stages"] = data.get("stages", {})
existing_entry = stage.name in data["stages"]
action = "Modifying" if existing_entry else "Adding"
logger.info(
"%s stage '%s' in '%s'", action, stage.name, self.relpath
)
if existing_entry:
orig_stage_data = data["stages"][stage.name]
if "meta" in orig_stage_data:
stage_data[stage.name]["meta"] = orig_stage_data["meta"]
apply_diff(stage_data[stage.name], orig_stage_data)
else:
data["stages"].update(stage_data)
dump_yaml(self.path, data)
self.repo.scm.track_file(self.relpath)
@property
@@ -239,6 +257,9 @@ class PipelineFile(FileMixin):
else:
super().remove()
def merge(self, ancestor, other):
raise NotImplementedError
class Lockfile(FileMixin):
from dvc.schema import COMPILED_LOCKFILE_SCHEMA as SCHEMA
@@ -246,8 +267,8 @@ class Lockfile(FileMixin):
def load(self):
if not self.exists():
return {}
with self.repo.tree.open(self.path) as fd:
data = parse_yaml(fd.read(), self.path)
data = load_yaml(self.path, tree=self.repo.tree)
try:
self.validate(data, fname=self.relpath)
except StageFileFormatError:
@@ -258,21 +279,18 @@ class Lockfile(FileMixin):
def dump(self, stage, **kwargs):
stage_data = serialize.to_lockfile(stage)
if not self.exists():
modified = True
logger.info("Generating lock file '%s'", self.relpath)
data = stage_data
open(self.path, "w+").close()
else:
with self.repo.tree.open(self.path, "r") as fd:
data = parse_yaml_for_update(fd.read(), self.path)
with modify_yaml(self.path, tree=self.repo.tree) as data:
if not data:
logger.info("Generating lock file '%s'", self.relpath)
modified = data.get(stage.name, {}) != stage_data.get(
stage.name, {}
)
if modified:
logger.info("Updating lock file '%s'", self.relpath)
data.update(stage_data)
dump_yaml(self.path, data)
if modified:
self.repo.scm.track_file(self.relpath)
@@ -295,6 +313,9 @@ class Lockfile(FileMixin):
else:
self.remove()
def merge(self, ancestor, other):
raise NotImplementedError
class Dvcfile:
def __new__(cls, repo, path, **kwargs):
+10 -16
View File
@@ -1,7 +1,7 @@
"""Exceptions raised by the dvc."""
from funcy import first
from dvc.utils import format_link, relpath
from dvc.utils import error_link, format_link, relpath
class DvcException(Exception):
@@ -171,11 +171,7 @@ class BadMetricError(DvcException):
class NoMetricsError(DvcException):
def __init__(self):
super().__init__(
"no metric files in this repository. Use `-m/-M` options for "
"`dvc run` to mark stage outputs as metrics."
)
pass
class NoPlotsError(DvcException):
@@ -186,14 +182,6 @@ class NoPlotsError(DvcException):
)
class YAMLFileCorruptedError(DvcException):
def __init__(self, path):
path = relpath(path)
super().__init__(
f"unable to read: '{path}', YAML file structure is corrupted"
)
class RecursiveAddingWhileUsingFilename(DvcException):
def __init__(self):
super().__init__(
@@ -268,8 +256,10 @@ class CheckoutError(DvcException):
self.stats = stats
targets = [str(t) for t in target_infos]
m = (
"Checkout failed for following targets:\n{}\nDid you "
"forget to fetch?".format("\n".join(targets))
"Checkout failed for following targets:\n{}\nIs your "
"cache up to date?\n{}".format(
"\n".join(targets), error_link("missing-files"),
)
)
super().__init__(m)
@@ -344,3 +334,7 @@ class NoOutputOrStageError(DvcException):
f"'{target}' "
f"does not exist as an output or a stage name in '{file}'"
)
class MergeError(DvcException):
pass
+29 -9
View File
@@ -18,10 +18,11 @@ from dvc.exceptions import (
)
from dvc.path_info import PathInfo
from dvc.repo import Repo
from dvc.repo.tree import RepoTree
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__)
@@ -90,15 +91,26 @@ class BaseExternalRepo:
"""Use the specified cache in place of default tmpdir cache for
download operations.
"""
if hasattr(self, "cache"):
save_cache = self.cache.local
self.cache.local = cache
has_cache = hasattr(self, "cache")
if has_cache:
save_cache = self.cache.local # pylint: disable=E0203
self.cache.local = cache # pylint: disable=E0203
else:
from collections import namedtuple
mock_cache = namedtuple("MockCache", ["local"])(local=cache)
self.cache = mock_cache # pylint: disable=W0201
self._local_cache = cache
yield
if hasattr(self, "cache"):
if has_cache:
self.cache.local = save_cache
else:
del self.cache
self._local_cache = None
@cached_property
@@ -130,14 +142,17 @@ class BaseExternalRepo:
for path in paths:
if not self.repo_tree.exists(path):
raise PathMissingError(path, self.url)
save_info = self.local_cache.save(
hash_info = self.repo_tree.save_info(
path, download_callback=download_update
)
self.local_cache.save(
path,
self.repo_tree,
None,
hash_info,
save_link=False,
download_callback=download_update,
)
save_infos.append(save_info)
save_infos.append(hash_info)
return sum(download_results), failed, save_infos
@@ -351,6 +366,11 @@ def _remove(path):
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)
os_retry(remove)(path)
try:
os_retry(remove)(path)
except PermissionError:
logger.warning(
"Failed to remove '%s'", relpath(path), exc_info=True
)
else:
remove(path)
+10
View File
@@ -0,0 +1,10 @@
from dataclasses import dataclass
@dataclass
class HashInfo:
name: str
value: str
def __bool__(self):
return bool(self.value)
+123 -23
View File
@@ -1,16 +1,17 @@
import logging
import os
import re
from collections import namedtuple
from itertools import groupby, takewhile
from pathspec.patterns import GitWildMatchPattern
from pathspec.util import normalize_file
from pygtrie import StringTrie
from dvc.path_info import PathInfo
from dvc.pathspec_math import merge_patterns
from dvc.pathspec_math import PatternInfo, merge_patterns
from dvc.system import System
from dvc.utils import relpath
from dvc.utils.collections import PathStringTrie
logger = logging.getLogger(__name__)
@@ -24,18 +25,26 @@ class DvcIgnore:
class DvcIgnorePatterns(DvcIgnore):
def __init__(self, pattern_list, dirname):
if pattern_list:
if isinstance(pattern_list[0], str):
pattern_list = [
PatternInfo(pattern, "") for pattern in pattern_list
]
self.pattern_list = pattern_list
self.dirname = dirname
self.prefix = self.dirname + os.sep
regex_pattern_list = map(
GitWildMatchPattern.pattern_to_regex, pattern_list
)
self.regex_pattern_list = [
GitWildMatchPattern.pattern_to_regex(pattern_info.patterns)
for pattern_info in pattern_list
]
self.ignore_spec = [
(ignore, re.compile("|".join(item[0] for item in group)))
for ignore, group in groupby(regex_pattern_list, lambda x: x[1])
for ignore, group in groupby(
self.regex_pattern_list, lambda x: x[1]
)
if ignore is not None
]
@@ -43,9 +52,19 @@ class DvcIgnorePatterns(DvcIgnore):
def from_files(cls, ignore_file_path, tree):
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:
path_spec_lines = [
line for line in map(str.strip, fobj.readlines()) if line
PatternInfo(
line,
"{}:{}:{}".format(ignore_file_rel_path, line_no + 1, line),
)
for line_no, line in enumerate(
map(str.strip, fobj.readlines())
)
if line and not (line.strip().startswith("#"))
]
return cls(path_spec_lines, dirname)
@@ -56,7 +75,7 @@ class DvcIgnorePatterns(DvcIgnore):
return dirs, files
def matches(self, dirname, basename, is_dir=False):
def _get_normalize_path(self, dirname, basename):
# NOTE: `relpath` is too slow, so we have to assume that both
# `dirname` and `self.dirname` are relative or absolute together.
if dirname == self.dirname:
@@ -70,6 +89,12 @@ class DvcIgnorePatterns(DvcIgnore):
if not System.is_unix():
path = normalize_file(path)
return path
def matches(self, dirname, basename, is_dir=False):
path = self._get_normalize_path(dirname, basename)
if not path:
return False
return self.ignore(path, is_dir)
def ignore(self, path, is_dir):
@@ -85,25 +110,57 @@ class DvcIgnorePatterns(DvcIgnore):
result = ignore
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):
result = []
for ignore, pattern in zip(self.regex_pattern_list, self.pattern_list):
regex = re.compile(ignore[0])
# skip system pattern
if not pattern.file_info:
continue
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)
return result
def __hash__(self):
return hash(self.dirname + ":" + "\n".join(self.pattern_list))
return hash(self.dirname + ":" + str(self.pattern_list))
def __eq__(self, other):
if not isinstance(other, DvcIgnorePatterns):
return NotImplemented
return (self.dirname == other.dirname) & (
self.pattern_list == other.pattern_list
[pattern.patterns for pattern in self.pattern_list]
== [pattern.patterns for pattern in other.pattern_list]
)
def __bool__(self):
return bool(self.pattern_list)
CheckIgnoreResult = namedtuple(
"CheckIgnoreResult", ["file", "match", "patterns"]
)
def _no_match(path):
return CheckIgnoreResult(path, False, ["::"])
class DvcIgnoreFilterNoop:
def __init__(self, tree, root_dir):
pass
def __call__(self, root, dirs, files):
def __call__(self, root, dirs, files, **kwargs):
return dirs, files
def is_ignored_dir(self, _):
@@ -112,6 +169,9 @@ class DvcIgnoreFilterNoop:
def is_ignored_file(self, _):
return False
def check_ignore(self, path):
return _no_match(path)
class DvcIgnoreFilter:
@staticmethod
@@ -127,10 +187,11 @@ class DvcIgnoreFilter:
self.tree = tree
self.root_dir = root_dir
self.ignores_trie_tree = StringTrie(separator=os.sep)
self.ignores_trie_tree = PathStringTrie()
self.ignores_trie_tree[root_dir] = DvcIgnorePatterns(
default_ignore_patterns, root_dir
)
self._ignored_subrepos = PathStringTrie()
self._update(self.root_dir)
def _update(self, dirname):
@@ -166,27 +227,30 @@ class DvcIgnoreFilter:
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}
new_pattern = DvcIgnorePatterns([f"/{d}/"], 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,
["/{}/".format(d)],
root,
new_pattern.pattern_list,
new_pattern.dirname,
)
)
else:
self.ignores_trie_tree[root] = DvcIgnorePatterns(
["/{}/".format(d)], root
)
self.ignores_trie_tree[root] = new_pattern
def __call__(self, root, dirs, files):
def __call__(self, root, dirs, files, ignore_subrepos=True):
ignore_pattern = self._get_trie_pattern(root)
if ignore_pattern:
return ignore_pattern(root, dirs, files)
else:
return dirs, files
dirs, files = ignore_pattern(root, dirs, files)
if not ignore_subrepos:
dirs.extend(self._ignored_subrepos.get(root, []))
return dirs, files
def _get_trie_pattern(self, dirname):
ignore_pattern = self.ignores_trie_tree.get(dirname)
@@ -214,7 +278,7 @@ class DvcIgnoreFilter:
def _is_ignored(self, path, is_dir=False):
if self._outside_repo(path):
return True
return False
dirname, basename = os.path.split(os.path.normpath(path))
ignore_pattern = self._get_trie_pattern(dirname)
if ignore_pattern:
@@ -222,14 +286,21 @@ class DvcIgnoreFilter:
else:
return False
def is_ignored_dir(self, path):
def _is_subrepo(self, path):
dirname, basename = os.path.split(os.path.normpath(path))
return basename in self._ignored_subrepos.get(dirname, set())
def is_ignored_dir(self, path, ignore_subrepos=True):
path = os.path.abspath(path)
if not ignore_subrepos:
return not self._is_subrepo(path)
if path == self.root_dir:
return False
return self._is_ignored(path, True)
def is_ignored_file(self, path):
path = os.path.abspath(path)
return self._is_ignored(path, False)
def _outside_repo(self, path):
@@ -245,3 +316,32 @@ class DvcIgnoreFilter:
):
return True
return False
def check_ignore(self, target):
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)
)
if matches:
return CheckIgnoreResult(target, True, matches)
return _no_match(target)
def init(path):
dvcignore = os.path.join(path, DvcIgnore.DVCIGNORE_FILE)
if os.path.exists(dvcignore):
return dvcignore
with open(dvcignore, "w") as fobj:
fobj.write(
"# Add patterns of files dvc should ignore, which could improve\n"
"# the performance. Learn more at\n"
"# https://dvc.org/doc/user-guide/dvcignore\n"
)
return dvcignore
+47 -30
View File
@@ -2,6 +2,7 @@
import errno
import logging
from contextlib import contextmanager
from dvc import analytics
from dvc.cli import parse_args
@@ -10,7 +11,7 @@ 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 format_link
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
@@ -21,7 +22,26 @@ from dvc.utils import format_link
logger = logging.getLogger("dvc")
def main(argv=None):
@contextmanager
def profile(enable, dump):
if not enable:
yield
return
import cProfile
prof = cProfile.Profile()
prof.enable()
yield
if not dump:
prof.print_stats(sort="cumtime")
return
prof.dump_stats(dump)
def main(argv=None): # noqa: C901
"""Run dvc CLI command.
Args:
@@ -37,20 +57,22 @@ def main(argv=None):
try:
args = parse_args(argv)
verbosity = args.verbose - args.quiet
if verbosity:
logger.setLevel(
{
-2: logging.CRITICAL,
-1: logging.ERROR,
1: logging.DEBUG,
2: logging.TRACE,
}[max(-2, min(verbosity, 2))]
)
level = None
if args.quiet:
level = logging.CRITICAL
elif args.verbose == 1:
level = logging.DEBUG
elif args.verbose > 1:
level = logging.TRACE
if level is not None:
logger.setLevel(level)
logger.trace(args)
cmd = args.func(args)
ret = cmd.run()
with profile(enable=args.cprofile, dump=args.cprofile_dump):
cmd = args.func(args)
ret = cmd.run()
except ConfigError:
logger.exception("configuration error")
ret = 251
@@ -65,22 +87,25 @@ def main(argv=None):
except DvcException:
ret = 255
logger.exception("")
except OSError as exc:
if exc.errno == errno.EMFILE:
except Exception as exc: # noqa, pylint: disable=broad-except
# pylint: disable=no-member
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
logger.exception(
"too many open files, please visit "
"{} to see how to handle this "
"problem".format(
format_link("https://error.dvc.org/many-files")
),
"problem".format(error_link("many-files")),
extra={"tb_only": True},
)
else:
logger.exception("unexpected error")
logger.info(FOOTER)
ret = 255
except Exception: # noqa, pylint: disable=broad-except
logger.exception("unexpected error")
ret = 255
try:
if analytics.is_enabled():
analytics.collect_and_send_report(args, ret)
return ret
finally:
logger.setLevel(outerLogLevel)
@@ -92,11 +117,3 @@ def main(argv=None):
# 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()
if ret != 0:
logger.info(FOOTER)
if analytics.is_enabled():
analytics.collect_and_send_report(args, ret)
return ret
+58 -15
View File
@@ -10,6 +10,7 @@ from dvc.cache import NamedCache
from dvc.exceptions import (
CollectCacheError,
DvcException,
MergeError,
RemoteCacheRequiredError,
)
@@ -45,6 +46,12 @@ class OutputIsStageFileError(DvcException):
super().__init__(f"Stage file '{path}' cannot be an output.")
class OutputIsIgnoredError(DvcException):
def __init__(self, match):
lines = "\n".join(match.patterns)
super().__init__(f"Path '{match.file}' is ignored by\n{lines}")
class BaseOutput:
IS_DEPENDENCY = False
@@ -77,6 +84,7 @@ class BaseOutput:
DoesNotExistError = OutputDoesNotExistError
IsNotFileOrDirError = OutputIsNotFileOrDirError
IsStageFileError = OutputIsStageFileError
IsIgnoredError = OutputIsIgnoredError
sep = "/"
@@ -91,7 +99,7 @@ class BaseOutput:
plot=False,
persist=False,
):
self._validate_output_path(path)
self._validate_output_path(path, stage)
# This output (and dependency) objects have too many paths/urls
# here is a list and comments:
#
@@ -164,20 +172,12 @@ class BaseOutput:
def cache_path(self):
return self.cache.tree.hash_to_path_info(self.checksum).url
@property
def checksum_type(self):
return self.tree.PARAM_CHECKSUM
@property
def checksum(self):
return self.info.get(self.tree.PARAM_CHECKSUM)
@checksum.setter
def checksum(self, checksum):
self.info[self.tree.PARAM_CHECKSUM] = checksum
def get_checksum(self):
return self.tree.get_hash(self.path_info)
return self.tree.get_hash(self.path_info).value
@property
def is_dir_checksum(self):
@@ -199,10 +199,7 @@ class BaseOutput:
return self.cache.changed_cache(self.checksum, filter_info=filter_info)
def status(self):
if self.checksum and self.use_cache and self.changed_cache():
return {str(self): "not in cache"}
def workspace_status(self):
if not self.exists:
return {str(self): "deleted"}
@@ -214,6 +211,12 @@ class BaseOutput:
return {}
def status(self):
if self.checksum and self.use_cache and self.changed_cache():
return {str(self): "not in cache"}
return self.workspace_status()
def changed(self):
status = self.status()
logger.debug(str(status))
@@ -280,6 +283,7 @@ class BaseOutput:
self.info = self.save_info()
def commit(self):
assert self.info
if self.use_cache:
self.cache.save(self.path_info, self.cache.tree, self.info)
@@ -495,8 +499,47 @@ class BaseOutput:
return ret
@classmethod
def _validate_output_path(cls, path):
def _validate_output_path(cls, path, stage=None):
from dvc.dvcfile import is_valid_filename
if is_valid_filename(path):
raise cls.IsStageFileError(path)
if stage:
check = stage.repo.tree.dvcignore.check_ignore(path)
if check.match:
raise cls.IsIgnoredError(check)
def _check_can_merge(self, out):
if self.scheme != out.scheme:
raise MergeError("unable to auto-merge outputs of different types")
my = self.dumpd()
other = out.dumpd()
my.pop(self.tree.PARAM_CHECKSUM)
other.pop(self.tree.PARAM_CHECKSUM)
if my != other:
raise MergeError(
"unable to auto-merge outputs with different options"
)
if not out.is_dir_checksum:
raise MergeError(
"unable to auto-merge outputs that are not directories"
)
def merge(self, ancestor, other):
assert other
if ancestor:
self._check_can_merge(ancestor)
ancestor_info = ancestor.info
else:
ancestor_info = None
self._check_can_merge(self)
self._check_can_merge(other)
self.info = self.cache.merge(ancestor_info, self.info, other.info)
+3 -2
View File
@@ -87,8 +87,9 @@ class LocalOutput(BaseOutput):
name = "metrics" if self.metric else "plot"
if os.path.isdir(path):
msg = "directory '{}' cannot be used as {}."
raise IsADirectoryError(msg.format(self.path_info, name))
msg = "directory '%s' cannot be used as %s."
logger.debug(msg, str(self.path_info), name)
return
if not istextfile(path):
msg = "binary file '{}' cannot be used as {}."
+8
View File
@@ -315,3 +315,11 @@ class HTTPURLInfo(URLInfo):
and self._path == other._path
and self._extra_parts == other._extra_parts
)
class WebDAVURLInfo(URLInfo):
@cached_property
def url(self):
return "{}://{}{}".format(
self.scheme.replace("webdav", "http"), self.netloc, self._spath
)
+7 -1
View File
@@ -3,11 +3,14 @@
# of two path specification patterns with different base
# All the operations follow the documents of `gitignore`
import os
from collections import namedtuple
from pathspec.util import normalize_file
from dvc.utils import relpath
PatternInfo = namedtuple("PatternInfo", ["patterns", "file_info"])
def _not_ignore(rule):
return (True, rule[1:]) if rule.startswith("!") else (False, rule)
@@ -59,7 +62,10 @@ def _change_dirname(dirname, pattern_list, new_dirname):
if rel.startswith(".."):
raise ValueError("change dirname can only change to parent path")
return [change_rule(rule, rel) for rule in pattern_list]
return [
PatternInfo(change_rule(rule.patterns, rel), rule.file_info)
for rule in pattern_list
]
def merge_patterns(pattern_a, prefix_a, pattern_b, prefix_b):
+2 -2
View File
@@ -7,7 +7,7 @@ from getpass import getpass
logger = logging.getLogger(__name__)
def _ask(prompt, limited_to=None):
def ask(prompt, limited_to=None):
if not sys.stdout.isatty():
return None
@@ -39,7 +39,7 @@ def confirm(statement):
bool: whether or not specified statement was confirmed.
"""
prompt = f"{statement} [y/n]"
answer = _ask(prompt, limited_to=["yes", "no", "y", "n"])
answer = ask(prompt, limited_to=["yes", "no", "y", "n"])
return answer and answer.startswith("y")
+26 -26
View File
@@ -15,7 +15,7 @@ from dvc.exceptions import (
OutputNotFoundError,
)
from dvc.path_info import PathInfo
from dvc.repo.tree import RepoTree
from dvc.tree.repo import RepoTree
from dvc.utils.fs import path_isin
from ..stage.exceptions import StageFileDoesNotExistError, StageNotFound
@@ -42,43 +42,43 @@ def locked(f):
class Repo:
DVC_DIR = ".dvc"
from dvc.repo.destroy import destroy
from dvc.repo.install import install
from dvc.repo.add import add
from dvc.repo.remove import remove
from dvc.repo.ls import ls
from dvc.repo.freeze import freeze, unfreeze
from dvc.repo.move import move
from dvc.repo.run import run
from dvc.repo.imp import imp
from dvc.repo.imp_url import imp_url
from dvc.repo.reproduce import reproduce
from dvc.repo.checkout import _checkout
from dvc.repo.push import push
from dvc.repo.fetch import _fetch
from dvc.repo.pull import pull
from dvc.repo.status import status
from dvc.repo.gc import gc
from dvc.repo.commit import commit
from dvc.repo.diff import diff
from dvc.repo.brancher import brancher
from dvc.repo.checkout import _checkout
from dvc.repo.commit import commit
from dvc.repo.destroy import destroy
from dvc.repo.diff import diff
from dvc.repo.fetch import _fetch
from dvc.repo.freeze import freeze, unfreeze
from dvc.repo.gc import gc
from dvc.repo.get import get
from dvc.repo.get_url import get_url
from dvc.repo.imp import imp
from dvc.repo.imp_url import imp_url
from dvc.repo.install import install
from dvc.repo.ls import ls
from dvc.repo.move import move
from dvc.repo.pull import pull
from dvc.repo.push import push
from dvc.repo.remove import remove
from dvc.repo.reproduce import reproduce
from dvc.repo.run import run
from dvc.repo.status import status
from dvc.repo.update import update
def __init__(self, root_dir=None, scm=None, rev=None):
from dvc.state import State, StateNoop
from dvc.lock import make_lock
from dvc.scm import SCM
from dvc.cache import Cache
from dvc.data_cloud import DataCloud
from dvc.lock import make_lock
from dvc.repo.experiments import Experiments
from dvc.repo.metrics import Metrics
from dvc.repo.plots import Plots
from dvc.repo.params import Params
from dvc.repo.plots import Plots
from dvc.scm import SCM
from dvc.stage.cache import StageCache
from dvc.state import State, StateNoop
from dvc.tree.local import LocalTree
from dvc.utils.fs import makedirs
from dvc.stage.cache import StageCache
if scm:
tree = scm.get_tree(rev)
@@ -441,10 +441,11 @@ class Repo:
"""
import networkx as nx
from pygtrie import Trie
from dvc.exceptions import (
OutputDuplicationError,
StagePathAsOutputError,
OverlappingOutputPathsError,
StagePathAsOutputError,
)
G = nx.DiGraph()
@@ -615,4 +616,3 @@ class Repo:
self.__dict__.pop("graph", None)
self.__dict__.pop("stages", None)
self.__dict__.pop("pipelines", None)
self.tree.__dict__.pop("dvcignore", None)
+1 -1
View File
@@ -37,7 +37,7 @@ def diff(self, a_rev="HEAD", b_rev=None):
def _to_checksum(output):
if on_working_tree:
return self.cache.local.tree.get_hash(output.path_info)
return self.cache.local.tree.get_hash(output.path_info).value
return output.checksum
def _exists(output):
+474 -57
View File
@@ -1,21 +1,66 @@
import logging
import os
import re
import tempfile
from collections.abc import Mapping
from concurrent.futures import (
ProcessPoolExecutor,
ThreadPoolExecutor,
as_completed,
)
from contextlib import contextmanager
from functools import partial, wraps
from typing import Iterable, Optional
from funcy import cached_property
from funcy import cached_property, first
from dvc.exceptions import DvcException
from dvc.exceptions import DownloadError, DvcException, UploadError
from dvc.path_info import PathInfo
from dvc.progress import Tqdm
from dvc.repo.experiments.executor import ExperimentExecutor, LocalExecutor
from dvc.scm.git import Git
from dvc.stage.serialize import to_lockfile
from dvc.tree.repo import RepoTree
from dvc.utils import dict_sha256, env2bool, relpath
from dvc.utils.fs import remove
logger = logging.getLogger(__name__)
def scm_locked(f):
# Lock the experiments workspace so that we don't try to perform two
# different sequences of git operations at once
@wraps(f)
def wrapper(exp, *args, **kwargs):
with exp.scm_lock:
return f(exp, *args, **kwargs)
return wrapper
def hash_exp(stages):
exp_data = {}
for stage in stages:
exp_data.update(to_lockfile(stage))
return dict_sha256(exp_data)
class UnchangedExperimentError(DvcException):
pass
def __init__(self, rev):
super().__init__(f"Experiment identical to baseline '{rev[:7]}'.")
self.rev = rev
class BaselineMismatchError(DvcException):
def __init__(self, rev, expected):
if hasattr(rev, "hexsha"):
rev = rev.hexsha
rev_str = f"{rev[:7]}" if rev is not None else "dangling commit"
super().__init__(
f"Experiment derived from '{rev_str}', expected '{expected[:7]}'."
)
self.rev = rev
self.expected_rev = expected
class Experiments:
@@ -26,8 +71,18 @@ class Experiments:
"""
EXPERIMENTS_DIR = "experiments"
PACKED_ARGS_FILE = "repro.dat"
STASH_MSG_PREFIX = "dvc-exp-"
STASH_EXPERIMENT_RE = re.compile(
r"(?:On \(.*\): )dvc-exp-(?P<baseline_rev>[0-9a-f]+)$"
)
BRANCH_RE = re.compile(
r"^(?P<baseline_rev>[a-f0-9]{7})-(?P<exp_sha>[a-f0-9]+)$"
)
def __init__(self, repo):
from dvc.lock import make_lock
if not (
env2bool("DVC_TEST")
or repo.config["core"].get("experiments", False)
@@ -35,6 +90,10 @@ class Experiments:
raise NotImplementedError
self.repo = repo
self.scm_lock = make_lock(
os.path.join(self.repo.tmp_dir, "exp_scm_lock"),
tmp_dir=self.repo.tmp_dir,
)
@cached_property
def exp_dir(self):
@@ -47,10 +106,13 @@ class Experiments:
return Git(self.exp_dir)
return self._init_clone()
@cached_property
def dvc_dir(self):
return relpath(self.repo.dvc_dir, self.repo.scm.root_dir)
@cached_property
def exp_dvc_dir(self):
dvc_dir = relpath(self.repo.dvc_dir, self.repo.scm.root_dir)
return os.path.join(self.exp_dir, dvc_dir)
return os.path.join(self.exp_dir, self.dvc_dir)
@cached_property
def exp_dvc(self):
@@ -59,20 +121,32 @@ class Experiments:
return Repo(self.exp_dvc_dir)
@staticmethod
def exp_hash(stages):
exp_data = {}
for stage in stages:
exp_data.update(to_lockfile(stage))
return dict_sha256(exp_data)
@contextmanager
def chdir(self):
cwd = os.getcwd()
os.chdir(self.exp_dvc.root_dir)
yield
yield self.exp_dvc.root_dir
os.chdir(cwd)
@cached_property
def args_file(self):
return os.path.join(self.exp_dvc.tmp_dir, self.PACKED_ARGS_FILE)
@property
def stash_reflog(self):
if "refs/stash" in self.scm.repo.refs:
return self.scm.repo.refs["refs/stash"].log()
return []
@property
def stash_revs(self):
revs = {}
for i, entry in enumerate(self.stash_reflog):
m = self.STASH_EXPERIMENT_RE.match(entry.message)
if m:
revs[entry.newhexsha] = (i, m.group("baseline_rev"))
return revs
def _init_clone(self):
src_dir = self.repo.scm.root_dir
logger.debug("Initializing experiments clone")
@@ -90,32 +164,101 @@ class Experiments:
def _scm_checkout(self, rev):
self.scm.repo.git.reset(hard=True)
if self.scm.repo.head.is_detached:
self._checkout_default_branch()
if not Git.is_sha(rev) or not self.scm.has_rev(rev):
self.scm.fetch(all=True)
logger.debug("Checking out base experiment commit '%s'", rev)
self.scm.pull()
logger.debug("Checking out experiment commit '%s'", rev)
self.scm.checkout(rev)
def _patch_exp(self):
"""Create a patch based on the current (parent) workspace and apply it
to the experiment workspace.
def _checkout_default_branch(self):
# switch to default branch
git_repo = self.scm.repo
origin_refs = git_repo.remotes["origin"].refs
ref = origin_refs["HEAD"].reference
branch_name = ref.name.split("/")[-1]
if branch_name in git_repo.heads:
branch = git_repo.heads[branch_name]
else:
branch = git_repo.create_head(branch_name, ref)
branch.set_tracking_branch(ref)
branch.checkout()
def _stash_exp(self, *args, params: Optional[dict] = None, **kwargs):
"""Stash changes from the current (parent) workspace as an experiment.
Args:
params: Optional dictionary of parameter values to be used.
Values take priority over any parameters specified in the
user's workspace.
"""
rev = self.scm.get_rev()
# patch user's workspace into experiments clone
tmp = tempfile.NamedTemporaryFile(delete=False).name
try:
self.repo.scm.repo.git.diff(patch=True, output=tmp)
self.repo.scm.repo.git.diff(
patch=True, full_index=True, binary=True, output=tmp
)
if os.path.getsize(tmp):
logger.debug("Patching experiment workspace")
self.scm.repo.git.apply(tmp)
else:
raise UnchangedExperimentError(
"Experiment identical to baseline commit."
)
elif not params:
# experiment matches original baseline
raise UnchangedExperimentError(rev)
finally:
remove(tmp)
def _commit(self, stages, check_exists=True, branch=True, rev=None):
# update experiment params from command line
if params:
self._update_params(params)
# save additional repro command line arguments
self._pack_args(*args, **kwargs)
# save experiment as a stash commit w/message containing baseline rev
# (stash commits are merge commits and do not contain a parent commit
# SHA)
msg = f"{self.STASH_MSG_PREFIX}{rev}"
self.scm.repo.git.stash("push", "-m", msg)
return self.scm.resolve_rev("stash@{0}")
def _pack_args(self, *args, **kwargs):
ExperimentExecutor.pack_repro_args(self.args_file, *args, **kwargs)
self.scm.add(self.args_file)
def _unpack_args(self, tree=None):
return ExperimentExecutor.unpack_repro_args(self.args_file, tree=tree)
def _update_params(self, params: dict):
"""Update experiment params files with the specified values."""
from dvc.utils.serialize import MODIFIERS
logger.debug("Using experiment params '%s'", params)
# recursive dict update
def _update(dict_, other):
for key, value in other.items():
if isinstance(value, Mapping):
dict_[key] = _update(dict_.get(key, {}), value)
else:
dict_[key] = value
return dict_
for params_fname in params:
path = PathInfo(self.exp_dvc.root_dir) / params_fname
suffix = path.suffix.lower()
modify_data = MODIFIERS[suffix]
with modify_data(path, tree=self.exp_dvc.tree) as data:
_update(data, params[params_fname])
def _commit(self, exp_hash, check_exists=True, branch=True):
"""Commit stages as an experiment and return the commit SHA."""
hash_ = self.exp_hash(stages)
exp_name = f"{rev[:7]}-{hash_}"
if not self.scm.is_dirty():
raise UnchangedExperimentError(self.scm.get_rev())
rev = self.scm.get_rev()
exp_name = f"{rev[:7]}-{exp_hash}"
if branch:
if check_exists and exp_name in self.scm.list_branches():
logger.debug("Using existing experiment branch '%s'", exp_name)
@@ -126,12 +269,34 @@ class Experiments:
self.scm.commit(f"Add experiment {exp_name}")
return self.scm.get_rev()
def _reproduce(self, *args, **kwargs):
"""Run `dvc repro` inside the experiments workspace."""
with self.chdir():
return self.exp_dvc.reproduce(*args, **kwargs)
def reproduce_one(self, queue=False, **kwargs):
"""Reproduce and checkout a single experiment."""
stash_rev = self.new(**kwargs)
if queue:
logger.info(
"Queued experiment '%s' for future execution.", stash_rev[:7]
)
return [stash_rev]
results = self.reproduce([stash_rev], keep_stash=False)
exp_rev = first(results)
if exp_rev is not None:
self.checkout_exp(exp_rev)
return results
def new(self, *args, workspace=True, **kwargs):
def reproduce_queued(self, **kwargs):
results = self.reproduce(**kwargs)
if results:
revs = [f"{rev[:7]}" for rev in results]
logger.info(
"Successfully reproduced experiment(s) '%s'.\n"
"Use `dvc exp checkout <exp_rev>` to apply the results of "
"a specific experiment to your workspace.",
", ".join(revs),
)
return results
@scm_locked
def new(self, *args, **kwargs):
"""Create a new experiment.
Experiment will be reproduced and checked out into the user's
@@ -139,47 +304,299 @@ class Experiments:
"""
rev = self.repo.scm.get_rev()
self._scm_checkout(rev)
if workspace:
try:
self._patch_exp()
except UnchangedExperimentError as exc:
logger.info("Reproducing existing experiment '%s'.", rev[:7])
raise exc
else:
# configure params via command line here
pass
self.exp_dvc.checkout()
stages = self._reproduce(*args, **kwargs)
exp_rev = self._commit(stages, rev=rev)
self.checkout_exp(exp_rev, force=True)
logger.info("Generated experiment '%s'.", exp_rev[:7])
return stages
try:
stash_rev = self._stash_exp(*args, **kwargs)
except UnchangedExperimentError as exc:
logger.info("Reproducing existing experiment '%s'.", rev[:7])
raise exc
logger.debug(
"Stashed experiment '%s' for future execution.", stash_rev[:7]
)
return stash_rev
def checkout_exp(self, rev, force=False):
@scm_locked
def reproduce(
self,
revs: Optional[Iterable] = None,
keep_stash: Optional[bool] = True,
**kwargs,
):
"""Reproduce the specified experiments.
Args:
revs: If revs is not specified, all stashed experiments will be
reproduced.
keep_stash: If True, stashed experiments will be preserved if they
fail to reproduce successfully.
"""
stash_revs = self.stash_revs
# to_run contains mapping of:
# input_rev: (stash_index, baseline_rev)
# where input_rev contains the changes to execute (usually a stash
# commit) and baseline_rev is the baseline to compare output against.
# The final experiment commit will be branched from baseline_rev.
if revs is None:
to_run = {
rev: baseline_rev
for rev, (_, baseline_rev) in stash_revs.items()
}
else:
to_run = {
rev: stash_revs[rev][1] if rev in stash_revs else rev
for rev in revs
}
logger.debug(
"Reproducing experiment revs '%s'",
", ".join((rev[:7] for rev in to_run)),
)
# setup executors - unstash experiment, generate executor, upload
# contents of (unstashed) exp workspace to the executor tree
executors = {}
for rev, baseline_rev in to_run.items():
self._scm_checkout(baseline_rev)
self.scm.repo.git.stash("apply", rev)
repro_args, repro_kwargs = self._unpack_args()
executor = LocalExecutor(
baseline_rev,
repro_args=repro_args,
repro_kwargs=repro_kwargs,
dvc_dir=self.dvc_dir,
cache_dir=self.repo.cache.local.cache_dir,
)
self._collect_input(executor)
executors[rev] = executor
exec_results = self._reproduce(executors, **kwargs)
if keep_stash:
# only drop successfully run stashed experiments
to_drop = sorted(
(
stash_revs[rev][0]
for rev in exec_results
if rev in stash_revs
),
reverse=True,
)
else:
# drop all stashed experiments
to_drop = sorted(
(stash_revs[rev][0] for rev in to_run if rev in stash_revs),
reverse=True,
)
for index in to_drop:
self.scm.repo.git.stash("drop", index)
result = {}
for _, exp_result in exec_results.items():
result.update(exp_result)
return result
def _reproduce(self, executors: dict, jobs: Optional[int] = 1) -> dict:
"""Run dvc repro for the specified ExperimentExecutors in parallel.
Returns dict containing successfully executed experiments.
"""
result = {}
with ProcessPoolExecutor(max_workers=jobs) as workers:
futures = {}
for rev, executor in executors.items():
future = workers.submit(
executor.reproduce,
executor.dvc_dir,
cwd=executor.dvc.root_dir,
**executor.repro_kwargs,
)
futures[future] = (rev, executor)
for future in as_completed(futures):
rev, executor = futures[future]
exc = future.exception()
if exc is None:
exp_hash = future.result()
self._scm_checkout(executor.baseline_rev)
try:
self._collect_output(executor)
except DownloadError:
logger.error(
"Failed to collect output for experiment '%s'",
rev,
)
continue
finally:
if os.path.exists(self.args_file):
remove(self.args_file)
try:
exp_rev = self._commit(exp_hash)
except UnchangedExperimentError:
logger.debug(
"Experiment '%s' identical to baseline '%s'",
rev,
executor.baseline_rev,
)
exp_rev = executor.baseline_rev
logger.info("Reproduced experiment '%s'.", exp_rev[:7])
result[rev] = {exp_rev: exp_hash}
else:
logger.exception(
"Failed to reproduce experiment '%s'", rev
)
executor.cleanup()
return result
def _collect_input(self, executor: ExperimentExecutor):
"""Copy (upload) input from the experiments workspace to the executor
tree.
"""
logger.debug("Collecting input for '%s'", executor.tmp_dir)
repo_tree = RepoTree(self.exp_dvc)
self._process(
executor.tree,
self.exp_dvc.tree,
executor.collect_files(self.exp_dvc.tree, repo_tree),
)
def _collect_output(self, executor: ExperimentExecutor):
"""Copy (download) output from the executor tree into experiments
workspace.
"""
logger.debug("Collecting output from '%s'", executor.tmp_dir)
self._process(
self.exp_dvc.tree,
executor.tree,
executor.collect_output(),
download=True,
)
@staticmethod
def _process(dest_tree, src_tree, collected_files, download=False):
from dvc.cache.local import _log_exceptions
from_infos = []
to_infos = []
names = []
for from_info in collected_files:
from_infos.append(from_info)
fname = from_info.relative_to(src_tree.path_info)
names.append(str(fname))
to_infos.append(dest_tree.path_info / fname)
total = len(from_infos)
if download:
func = partial(
_log_exceptions(src_tree.download, "download"),
dir_mode=dest_tree.dir_mode,
file_mode=dest_tree.file_mode,
)
desc = "Downloading"
else:
func = partial(_log_exceptions(dest_tree.upload, "upload"))
desc = "Uploading"
with Tqdm(total=total, unit="file", desc=desc) as pbar:
func = pbar.wrap_fn(func)
# TODO: parallelize this, currently --jobs for repro applies to
# number of repro executors not download threads
with ThreadPoolExecutor(max_workers=1) as dl_executor:
fails = sum(dl_executor.map(func, from_infos, to_infos, names))
if fails:
if download:
raise DownloadError(fails)
raise UploadError(fails)
@scm_locked
def checkout_exp(self, rev):
"""Checkout an experiment to the user's workspace."""
from git.exc import GitCommandError
from dvc.repo.checkout import _checkout as dvc_checkout
if force:
self.repo.scm.repo.git.reset(hard=True)
logger.debug(f"checkout {rev}")
self._check_baseline(rev)
self._scm_checkout(rev)
tmp = tempfile.NamedTemporaryFile(delete=False).name
self.scm.repo.head.commit.diff("HEAD~1", patch=True, output=tmp)
self.scm.repo.head.commit.diff(
"HEAD~1", patch=True, full_index=True, binary=True, output=tmp
)
dirty = self.repo.scm.is_dirty()
if dirty:
logger.debug("Stashing workspace changes.")
self.repo.scm.repo.git.stash("push", "--include-untracked")
try:
if os.path.getsize(tmp):
logger.debug("Patching local workspace")
self.repo.scm.repo.git.apply(tmp, reverse=True)
dvc_checkout(self.repo)
need_checkout = True
else:
need_checkout = False
except GitCommandError:
raise DvcException(
"Checkout failed, experiment contains changes which "
"conflict with your current workspace. To overwrite "
"your workspace, use `dvc experiments checkout --force`."
)
raise DvcException("failed to apply experiment changes.")
finally:
remove(tmp)
if dirty:
self._unstash_workspace()
if need_checkout:
dvc_checkout(self.repo)
def _check_baseline(self, exp_rev):
baseline_sha = self.repo.scm.get_rev()
exp_commit = self.scm.repo.rev_parse(exp_rev)
parent = first(exp_commit.parents)
if parent is not None and parent.hexsha == baseline_sha:
return
raise BaselineMismatchError(parent, baseline_sha)
def _unstash_workspace(self):
# Essentially we want `git stash pop` with `-X ours` merge strategy
# to prefer the applied experiment changes over stashed workspace
# changes. git stash doesn't support merge strategy parameters, but we
# can do it ourselves with checkout/reset.
from git.exc import GitCommandError
logger.debug("Unstashing workspace changes.")
git_repo = self.repo.scm.repo.git
# stage workspace changes, then apply stashed changes on top
git_repo.add(A=True)
try:
git_repo.stash("apply", "stash@{0}")
except GitCommandError:
# stash apply will return error code on merge conflicts,
# prefer workspace changes over stash changes
git_repo.checkout("--ours", "--", ".")
# unstage changes and drop the stash entry
git_repo.reset("HEAD")
git_repo.stash("drop", "stash@{0}")
@scm_locked
def get_baseline(self, rev):
"""Return the baseline rev for an experiment rev."""
from git.exc import GitCommandError
rev = self.scm.resolve_rev(rev)
try:
name = self.scm.repo.git.name_rev(rev, name_only=True)
except GitCommandError:
return None
if not name:
return None
if name in ("undefined", "stash"):
_, baseline = self.stash_revs.get(rev, (None, None))
return baseline
m = self.BRANCH_RE.match(name)
if m:
return self.scm.resolve_rev(m.group("baseline_rev"))
return None
def checkout(self, *args, **kwargs):
from dvc.repo.experiments.checkout import checkout
+163
View File
@@ -0,0 +1,163 @@
import logging
import os
import pickle
from tempfile import TemporaryDirectory
from typing import Iterable
from funcy import cached_property
from dvc.path_info import PathInfo
from dvc.stage import PipelineStage
from dvc.tree.base import BaseTree
from dvc.tree.local import LocalTree
from dvc.tree.repo import RepoTree
logger = logging.getLogger(__name__)
class ExperimentExecutor:
"""Base class for executing experiments in parallel.
Args:
baseline_rev: baseline revision that this experiment is derived from.
Optional keyword args:
repro_args: Args to be passed into reproduce.
repro_kwargs: Keyword args to be passed into reproduce.
"""
def __init__(self, baseline_rev: str, **kwargs):
self.baseline_rev = baseline_rev
self.repro_args = kwargs.pop("repro_args", [])
self.repro_kwargs = kwargs.pop("repro_kwargs", {})
@property
def tree(self) -> BaseTree:
raise NotImplementedError
@staticmethod
def reproduce(dvc_dir, cwd=None, **kwargs):
raise NotImplementedError
def collect_output(self) -> Iterable["PathInfo"]:
"""Iterate over output pathnames for this executor.
For DVC outs, only the .dvc file path will be yielded. DVC outs
themselves should be fetched from remote executor cache in the normal
fetch/pull way. For local executors outs will already be available via
shared local cache.
"""
raise NotImplementedError
def cleanup(self):
pass
# TODO: come up with better way to stash repro arguments
@staticmethod
def pack_repro_args(path, *args, tree=None, **kwargs):
open_func = tree.open if tree else open
data = {"args": args, "kwargs": kwargs}
with open_func(path, "wb") as fobj:
pickle.dump(data, fobj)
@staticmethod
def unpack_repro_args(path, tree=None):
open_func = tree.open if tree else open
with open_func(path, "rb") as fobj:
data = pickle.load(fobj)
return data["args"], data["kwargs"]
class LocalExecutor(ExperimentExecutor):
"""Local machine experiment executor."""
def __init__(self, baseline_rev: str, **kwargs):
from dvc.repo import Repo
dvc_dir = kwargs.pop("dvc_dir")
cache_dir = kwargs.pop("cache_dir")
super().__init__(baseline_rev, **kwargs)
self.tmp_dir = TemporaryDirectory()
# init empty DVC repo (will be overwritten when input is uploaded)
Repo.init(root_dir=self.tmp_dir.name, no_scm=True)
logger.debug(
"Init local executor in dir '%s' with baseline '%s'.",
self.tmp_dir,
baseline_rev[:7],
)
self.dvc_dir = os.path.join(self.tmp_dir.name, dvc_dir)
self._config(cache_dir)
self._tree = LocalTree(self.dvc, {"url": self.dvc.root_dir})
# override default CACHE_MODE since files must be writable in order
# to run repro
self._tree.CACHE_MODE = 0o644
def _config(self, cache_dir):
local_config = os.path.join(self.dvc_dir, "config.local")
logger.debug("Writing experiments local config '%s'", local_config)
with open(local_config, "w") as fobj:
fobj.write("[core]\n no_scm = true\n")
fobj.write(f"[cache]\n dir = {cache_dir}")
@cached_property
def dvc(self):
from dvc.repo import Repo
return Repo(self.dvc_dir)
@cached_property
def path_info(self):
return PathInfo(self.tmp_dir.name)
@property
def tree(self):
return self._tree
@staticmethod
def reproduce(dvc_dir, cwd=None, **kwargs):
"""Run dvc repro and return the result."""
from dvc.repo import Repo
from dvc.repo.experiments import hash_exp
unchanged = []
def filter_pipeline(stage):
if isinstance(stage, PipelineStage):
unchanged.append(stage)
if cwd:
old_cwd = os.getcwd()
os.chdir(cwd)
else:
old_cwd = None
cwd = os.getcwd()
try:
logger.debug("Running repro in '%s'", cwd)
dvc = Repo(dvc_dir)
dvc.checkout()
stages = dvc.reproduce(on_unchanged=filter_pipeline, **kwargs)
finally:
if old_cwd is not None:
os.chdir(old_cwd)
# ideally we would return stages here like a normal repro() call, but
# stages is not currently picklable and cannot be returned across
# multiprocessing calls
return hash_exp(stages + unchanged)
def collect_output(self) -> Iterable["PathInfo"]:
repo_tree = RepoTree(self.dvc)
yield from self.collect_files(self.tree, repo_tree)
@staticmethod
def collect_files(tree: BaseTree, repo_tree: RepoTree):
for fname in repo_tree.walk_files(repo_tree.root_dir, dvcfiles=True):
if not repo_tree.isdvc(fname):
yield tree.path_info / fname.relative_to(repo_tree.root_dir)
def cleanup(self):
logger.debug("Removing tmpdir '%s'", self.tmp_dir)
self.tmp_dir.cleanup()
super().cleanup()
+47 -13
View File
@@ -1,6 +1,6 @@
import logging
import re
from collections import OrderedDict, defaultdict
from datetime import datetime
from dvc.repo import locked
from dvc.repo.metrics.show import _collect_metrics, _read_metrics
@@ -9,36 +9,58 @@ from dvc.repo.params.show import _collect_configs, _read_params
logger = logging.getLogger(__name__)
EXP_RE = re.compile(r"(?P<rev_sha>[a-f0-9]{7})-(?P<exp_sha>[a-f0-9]+)")
def _collect_experiment(repo, branch, stash=False, sha_only=True):
from git.exc import GitCommandError
def _collect_experiment(repo, branch):
res = defaultdict(dict)
for rev in repo.brancher(revs=[branch]):
if rev == "workspace":
res["timestamp"] = None
else:
commit = repo.scm.repo.rev_parse(rev)
res["timestamp"] = datetime.fromtimestamp(commit.committed_date)
configs = _collect_configs(repo)
params = _read_params(repo, configs, rev)
if params:
res["params"] = params
metrics = _collect_metrics(repo, None, False)
vals = _read_metrics(repo, metrics, rev)
if vals:
res["queued"] = stash
if not stash:
metrics = _collect_metrics(repo, None, False)
vals = _read_metrics(repo, metrics, rev)
res["metrics"] = vals
if not sha_only and rev != "workspace":
try:
name = repo.scm.repo.git.describe(
rev, all=True, exact_match=True
)
name = name.rsplit("/")[-1]
res["name"] = name
except GitCommandError:
pass
return res
@locked
def show(
repo, all_branches=False, all_tags=False, revs=None, all_commits=False
repo,
all_branches=False,
all_tags=False,
revs=None,
all_commits=False,
sha_only=False,
):
res = defaultdict(OrderedDict)
if revs is None:
revs = [repo.scm.get_rev()]
revs = set(
repo.brancher(
revs = OrderedDict(
(rev, None)
for rev in repo.brancher(
revs=revs,
all_branches=all_branches,
all_tags=all_tags,
@@ -47,12 +69,15 @@ def show(
)
for rev in revs:
res[rev]["baseline"] = _collect_experiment(repo, rev)
res[rev]["baseline"] = _collect_experiment(
repo, rev, sha_only=sha_only
)
# collect reproduced experiments
for exp_branch in repo.experiments.scm.list_branches():
m = re.match(EXP_RE, exp_branch)
m = repo.experiments.BRANCH_RE.match(exp_branch)
if m:
rev = repo.scm.resolve_rev(m.group("rev_sha"))
rev = repo.scm.resolve_rev(m.group("baseline_rev"))
if rev in revs:
exp_rev = repo.experiments.scm.resolve_rev(exp_branch)
with repo.experiments.chdir():
@@ -61,4 +86,13 @@ def show(
)
res[rev][exp_rev] = experiment
# collect queued (not yet reproduced) experiments
for stash_rev, (_, baseline_rev) in repo.experiments.stash_revs.items():
if baseline_rev in revs:
with repo.experiments.chdir():
experiment = _collect_experiment(
repo.experiments.exp_dvc, stash_rev, stash=True
)
res[baseline_rev][stash_rev] = experiment
return res
+1
View File
@@ -50,6 +50,7 @@ def gc(
)
from contextlib import ExitStack
from dvc.repo import Repo
if not repos:
+1 -1
View File
@@ -20,8 +20,8 @@ class GetDVCFileError(DvcException):
@staticmethod
def get(url, path, out=None, rev=None):
from dvc.external_repo import external_repo
from dvc.dvcfile import is_valid_filename
from dvc.external_repo import external_repo
out = resolve_output(path, out)
+1
View File
@@ -1,5 +1,6 @@
def check_acyclic(graph):
import networkx as nx
from dvc.exceptions import CyclicGraphError
try:
+6 -1
View File
@@ -6,6 +6,7 @@ import colorama
from dvc import analytics
from dvc.config import Config
from dvc.exceptions import InitError, InvalidArgumentError
from dvc.ignore import init as init_dvcignore
from dvc.repo import Repo
from dvc.scm import SCM
from dvc.scm.base import SCMError
@@ -99,9 +100,13 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
with config.edit() as conf:
conf["core"]["no_scm"] = True
dvcignore = init_dvcignore(root_dir)
proj = Repo(root_dir)
scm.add([config.files["repo"], proj.plot_templates.templates_dir])
scm.add(
[config.files["repo"], dvcignore, proj.plot_templates.templates_dir]
)
if scm.ignore_file:
scm.add([os.path.join(dvc_dir, scm.ignore_file)])
+21 -35
View File
@@ -1,3 +1,5 @@
from itertools import chain
from dvc.exceptions import PathMissingError
from dvc.path_info import PathInfo
@@ -48,7 +50,7 @@ def ls(
def _ls(repo, path_info, recursive=None, dvc_only=False):
from dvc.repo.tree import RepoTree
from dvc.tree.repo import RepoTree
def onerror(exc):
raise exc
@@ -57,48 +59,32 @@ def _ls(repo, path_info, recursive=None, dvc_only=False):
# fetch directory listings, but don't want to fetch file contents.
tree = RepoTree(repo, stream=True)
ret = {}
infos = []
try:
for root, dirs, files in tree.walk(
path_info.fspath, onerror=onerror, dvcfiles=True
):
for fname in files:
info = PathInfo(root) / fname
dvc = tree.isdvc(info)
if dvc or not dvc_only:
path = str(info.relative_to(path_info))
ret[path] = {
"isout": dvc,
"isdir": False,
"isexec": False if dvc else tree.isexec(info),
}
entries = chain(files, dirs) if not recursive else files
infos.extend(PathInfo(root) / entry for entry in entries)
if not recursive:
for dname in dirs:
info = PathInfo(root) / dname
if not dvc_only or (
tree.dvctree and tree.dvctree.exists(info)
):
dvc = tree.isdvc(info)
path = str(info.relative_to(path_info))
ret[path] = {
"isout": dvc,
"isdir": True,
"isexec": False if dvc else tree.isexec(info),
}
break
except NotADirectoryError:
dvc = tree.isdvc(path_info)
if dvc or not dvc_only:
return {
path_info.name: {
"isout": dvc,
"isdir": False,
"isexec": False if dvc else tree.isexec(path_info),
}
}
return {}
infos.append(path_info)
except FileNotFoundError:
return {}
ret = {}
for info in infos:
metadata = tree.metadata(info)
if metadata.output_exists or not dvc_only:
path = (
path_info.name
if path_info == info
else str(info.relative_to(path_info))
)
ret[path] = {
"isout": metadata.is_output,
"isdir": metadata.isdir,
"isexec": metadata.is_exec,
}
return ret
+32 -11
View File
@@ -1,12 +1,11 @@
import logging
import os
import yaml
from dvc.exceptions import NoMetricsError
from dvc.path_info import PathInfo
from dvc.repo import locked
from dvc.repo.tree import RepoTree
from dvc.tree.repo import RepoTree
from dvc.utils.serialize import YAMLFileCorruptedError, load_yaml
logger = logging.getLogger(__name__)
@@ -36,7 +35,7 @@ def _collect_metrics(repo, targets, recursive):
return list(filter(_filter, metrics))
def _extract_metrics(metrics):
def _extract_metrics(metrics, path, rev):
if isinstance(metrics, (int, float)):
return metrics
@@ -45,9 +44,18 @@ def _extract_metrics(metrics):
ret = {}
for key, val in metrics.items():
m = _extract_metrics(val)
m = _extract_metrics(val, path, rev)
if m:
ret[key] = m
else:
logger.debug(
"Could not parse '%s' metric from '%s' at '%s' "
"due to its unsupported type: '%s'",
key,
path,
rev,
type(val).__name__,
)
return ret
@@ -61,16 +69,14 @@ def _read_metrics(repo, metrics, rev):
continue
try:
with tree.open(metric, "r") as fobj:
# NOTE this also supports JSON
val = yaml.safe_load(fobj)
except (FileNotFoundError, yaml.YAMLError):
val = load_yaml(metric, tree=tree)
except (FileNotFoundError, YAMLFileCorruptedError):
logger.debug(
"failed to read '%s' on '%s'", metric, rev, exc_info=True
)
continue
val = _extract_metrics(val)
val = _extract_metrics(val, metric, rev)
if val:
res[str(metric)] = val
@@ -88,6 +94,7 @@ def show(
all_commits=False,
):
res = {}
metrics_found = False
for rev in repo.brancher(
revs=revs,
@@ -96,13 +103,27 @@ def show(
all_commits=all_commits,
):
metrics = _collect_metrics(repo, targets, recursive)
if not metrics_found and metrics:
metrics_found = True
vals = _read_metrics(repo, metrics, rev)
if vals:
res[rev] = vals
if not res:
raise NoMetricsError()
if metrics_found:
msg = (
"Could not parse metric files. Use `-v` option to see more "
"details."
)
else:
msg = (
"no metric files in this repository. Use `-m/-M` options for "
"`dvc run` to mark stage outputs as metrics."
)
raise NoMetricsError(msg)
# Hide workspace metrics if they are the same as in the active branch
try:
+1
View File
@@ -34,6 +34,7 @@ def move(self, from_path, to_path):
"""
import dvc.output as Output
from dvc.stage import Stage
from ..dvcfile import DVC_FILE_SUFFIX, Dvcfile
from_out = Output.loads_from(Stage(self), [from_path])[0]
+10 -13
View File
@@ -1,12 +1,10 @@
import logging
import toml
import yaml
from dvc.dependency.param import ParamsDependency
from dvc.exceptions import DvcException
from dvc.path_info import PathInfo
from dvc.repo import locked
from dvc.utils.serialize import LOADERS, ParseError
logger = logging.getLogger(__name__)
@@ -33,16 +31,15 @@ def _read_params(repo, configs, rev):
if not repo.tree.exists(config):
continue
with repo.tree.open(config, "r") as fobj:
try:
res[str(config)] = ParamsDependency.PARAMS_FILE_LOADERS[
config.suffix.lower()
](fobj)
except (yaml.YAMLError, toml.TomlDecodeError):
logger.debug(
"failed to read '%s' on '%s'", config, rev, exc_info=True
)
continue
suffix = config.suffix.lower()
loader = LOADERS[suffix]
try:
res[str(config)] = loader(config, tree=repo.tree)
except ParseError:
logger.debug(
"failed to read '%s' on '%s'", config, rev, exc_info=True
)
continue
return res
+6 -4
View File
@@ -3,8 +3,8 @@ import logging
from funcy import first, project
from dvc.exceptions import DvcException, NoPlotsError, OutputNotFoundError
from dvc.repo.tree import RepoTree
from dvc.schema import PLOT_PROPS
from dvc.tree.repo import RepoTree
from dvc.utils import relpath
logger = logging.getLogger(__name__)
@@ -75,7 +75,7 @@ class Plots:
for datafile, desc in plots.items()
}
def show(self, targets=None, revs=None, props=None):
def show(self, targets=None, revs=None, props=None, templates=None):
from .data import NoMetricInHistoryError
data = self.collect(targets, revs)
@@ -90,7 +90,9 @@ class Plots:
if not data:
raise NoPlotsError()
return self.render(data, revs, props, self.repo.plot_templates)
if templates is None:
templates = self.repo.plot_templates
return self.render(data, revs, props, templates)
def diff(self, *args, **kwargs):
from .diff import diff
@@ -210,7 +212,7 @@ def _prepare_plots(data, revs, props):
def _render(datafile, datas, props, templates):
from .data import plot_data, PlotData
from .data import PlotData, plot_data
# Copy it to not modify a passed value
props = props.copy()
+9 -19
View File
@@ -5,11 +5,10 @@ import os
from collections import OrderedDict
from copy import copy
import yaml
from funcy import first
from yaml import SafeLoader
from dvc.exceptions import DvcException
from dvc.utils.serialize import loads_yaml
class PlotMetricTypeError(DvcException):
@@ -101,7 +100,7 @@ def _apply_path(data, path=None, **kwargs):
def _lists(dictionary):
for _, value in dictionary.items():
if isinstance(value, dict):
yield from (_lists(value))
yield from _lists(value)
elif isinstance(value, list):
yield value
@@ -114,10 +113,12 @@ def _find_data(data, fields=None, **kwargs):
# just look for first list of dicts
fields = set()
for l in _lists(data):
if all(isinstance(dp, dict) for dp in l):
if set(first(l).keys()) & fields == fields:
return l
for lst in _lists(data):
if (
all(isinstance(dp, dict) for dp in lst)
and set(first(lst).keys()) & fields == fields
):
return lst
raise PlotDataStructureError()
@@ -206,18 +207,7 @@ class CSVPlotData(PlotData):
class YAMLPlotData(PlotData):
def raw(self, **kwargs):
class OrderedLoader(SafeLoader):
pass
def construct_mapping(loader, node):
loader.flatten_mapping(node)
return OrderedDict(loader.construct_pairs(node))
OrderedLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_mapping
)
return yaml.load(self.content, OrderedLoader)
return loads_yaml(self.content, typ="rt")
def _processors(self):
parent_processors = super()._processors()
+15 -5
View File
@@ -1,13 +1,23 @@
def _revisions(revs, is_dirty):
def _revisions(repo, revs, experiment):
revisions = revs or []
if experiment and len(revisions) == 1:
baseline = repo.experiments.get_baseline(revisions[0])
if baseline:
revisions.append(baseline[:7])
if len(revisions) <= 1:
if len(revisions) == 0 and is_dirty:
if len(revisions) == 0 and repo.scm.is_dirty():
revisions.append("HEAD")
revisions.append("workspace")
return revisions
def diff(repo, *args, revs=None, **kwargs):
return repo.plots.show(
*args, revs=_revisions(revs, repo.scm.is_dirty()), **kwargs
def diff(repo, *args, revs=None, experiment=False, **kwargs):
if experiment:
# use experiments repo brancher with templates from the main repo
kwargs["templates"] = repo.plot_templates
plots_repo = repo.experiments.exp_dvc
else:
plots_repo = repo
return plots_repo.plots.show(
*args, revs=_revisions(repo, revs, experiment), **kwargs
)
+51 -6
View File
@@ -60,7 +60,7 @@ def reproduce(
recursive=False,
pipeline=False,
all_pipelines=False,
**kwargs
**kwargs,
):
from dvc.utils import parse_target
@@ -71,13 +71,22 @@ def reproduce(
)
experiment = kwargs.pop("experiment", False)
if experiment and self.experiments:
params = _parse_params(kwargs.pop("params", []))
queue = kwargs.pop("queue", False)
run_all = kwargs.pop("run_all", False)
jobs = kwargs.pop("jobs", 1)
if (experiment or run_all) and self.experiments:
try:
return self.experiments.new(
return _reproduce_experiments(
self,
target=target,
recursive=recursive,
all_pipelines=all_pipelines,
**kwargs
params=params,
queue=queue,
run_all=run_all,
jobs=jobs,
**kwargs,
)
except UnchangedExperimentError:
# If experiment contains no changes, just run regular repro
@@ -109,8 +118,41 @@ def reproduce(
return _reproduce_stages(active_graph, targets, **kwargs)
def _parse_params(path_params):
from ruamel.yaml import YAMLError
from dvc.dependency.param import ParamsDependency
from dvc.utils.flatten import unflatten
from dvc.utils.serialize import loads_yaml
ret = {}
for path_param in path_params:
path, _, params_str = path_param.rpartition(":")
# remove empty strings from params, on condition such as `-p "file1:"`
params = {}
for param_str in filter(bool, params_str.split(",")):
try:
# interpret value strings using YAML rules
key, value = param_str.split("=")
params[key] = loads_yaml(value)
except (ValueError, YAMLError):
raise InvalidArgumentError(
f"Invalid param/value pair '{param_str}'"
)
if not path:
path = ParamsDependency.DEFAULT_PARAMS_FILE
ret[path] = unflatten(params)
return ret
def _reproduce_experiments(repo, run_all=False, jobs=1, **kwargs):
if run_all:
return repo.experiments.reproduce_queued(jobs=jobs)
return repo.experiments.reproduce_one(**kwargs)
def _reproduce_stages(
G, stages, downstream=False, single_item=False, **kwargs
G, stages, downstream=False, single_item=False, on_unchanged=None, **kwargs
):
r"""Derive the evaluation of the given node for the given graph.
@@ -194,7 +236,10 @@ def _reproduce_stages(
# dependencies didn't change.
kwargs["force"] = True
result.extend(ret)
if ret:
result.extend(ret)
elif on_unchanged is not None:
on_unchanged(stage)
except Exception as exc:
raise ReproductionError(stage.relpath) from exc
+2 -2
View File
@@ -36,7 +36,7 @@ def parse_params(path_params):
def _get_file_path(kwargs):
from dvc.dvcfile import DVC_FILE_SUFFIX, DVC_FILE
from dvc.dvcfile import DVC_FILE, DVC_FILE_SUFFIX
out = first(
concat(
@@ -76,8 +76,8 @@ def _check_stage_exists(dvcfile, stage):
@locked
@scm_context
def run(self, fname=None, no_exec=False, single_stage=False, **kwargs):
from dvc.dvcfile import PIPELINE_FILE, Dvcfile
from dvc.stage import Stage, create_stage
from dvc.dvcfile import Dvcfile, PIPELINE_FILE
if not kwargs.get("cmd"):
raise InvalidArgumentError("command is not specified")
+21 -15
View File
@@ -10,31 +10,30 @@ from . import locked
logger = logging.getLogger(__name__)
def _joint_status(stages):
def _joint_status(pairs):
status_info = {}
for stage in stages:
for stage, filter_info in pairs:
if stage.frozen and not stage.is_repo_import:
logger.warning(
"{} is frozen. Its dependencies are"
" not going to be shown in the status output.".format(stage)
)
status_info.update(stage.status(check_updates=True))
status_info.update(
stage.status(check_updates=True, filter_info=filter_info)
)
return status_info
def _local_status(self, targets=None, with_deps=False, recursive=False):
if targets:
stages = cat(
self.collect(t, with_deps=with_deps, recursive=recursive)
for t in targets
)
else:
stages = self.collect(None, with_deps=with_deps, recursive=recursive)
targets = targets or [None]
pairs = cat(
self.collect_granular(t, with_deps=with_deps, recursive=recursive)
for t in targets
)
return _joint_status(stages)
return _joint_status(pairs)
def _cloud_status(
@@ -52,6 +51,7 @@ def _cloud_status(
- new: Remote doesn't have the file
- deleted: File is no longer in the local cache
- missing: File doesn't exist neither in the cache, neither in remote
Example:
Given the following commands:
@@ -89,14 +89,20 @@ def _cloud_status(
)
ret = {}
status_info = self.cloud.status(used, jobs, remote=remote)
status_info = self.cloud.status(
used, jobs, remote=remote, log_missing=False
)
for info in status_info.values():
name = info["name"]
status_ = info["status"]
if status_ in [cloud.STATUS_OK, cloud.STATUS_MISSING]:
if status_ == cloud.STATUS_OK:
continue
prefix_map = {cloud.STATUS_DELETED: "deleted", cloud.STATUS_NEW: "new"}
prefix_map = {
cloud.STATUS_DELETED: "deleted",
cloud.STATUS_NEW: "new",
cloud.STATUS_MISSING: "missing",
}
ret[name] = prefix_map[status_]
-444
View File
@@ -1,444 +0,0 @@
import logging
import os
from dvc.dvcfile import is_valid_filename
from dvc.exceptions import OutputNotFoundError
from dvc.path_info import PathInfo
from dvc.tree.base import BaseTree, RemoteActionNotImplemented
from dvc.utils import file_md5
from dvc.utils.fs import copy_fobj_to_file, makedirs
logger = logging.getLogger(__name__)
class DvcTree(BaseTree): # pylint:disable=abstract-method
"""DVC repo tree.
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.
"""
def __init__(self, repo, fetch=False, stream=False):
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)
def _is_cached(out):
return out.use_cache
outs = list(filter(_is_cached, outs))
if not outs:
raise OutputNotFoundError(path, self.repo)
return outs
def _get_granular_checksum(self, path, out, remote=None):
assert isinstance(path, PathInfo)
if not self.fetch and not self.stream:
raise FileNotFoundError
dir_cache = out.get_dir_cache(remote=remote)
for entry in dir_cache:
entry_relpath = entry[out.tree.PARAM_RELPATH]
if os.name == "nt":
entry_relpath = entry_relpath.replace("/", os.sep)
if path == out.path_info / entry_relpath:
return entry[out.tree.PARAM_CHECKSUM]
raise FileNotFoundError
def open(
self, path, mode="r", encoding="utf-8", remote=None
): # pylint: disable=arguments-differ
try:
outs = self._find_outs(path, strict=False)
except OutputNotFoundError as exc:
raise FileNotFoundError from exc
# NOTE: this handles both dirty and checkout-ed out at the same time
if self.repo.tree.exists(path):
return self.repo.tree.open(path, mode=mode, encoding=encoding)
if len(outs) != 1 or (
outs[0].is_dir_checksum and path == outs[0].path_info
):
raise IsADirectoryError
out = outs[0]
if out.changed_cache(filter_info=path):
if not self.fetch and not self.stream:
raise FileNotFoundError
remote_obj = self.repo.cloud.get_remote(remote)
if self.stream:
if out.is_dir_checksum:
checksum = self._get_granular_checksum(path, out)
else:
checksum = out.checksum
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)
if out.is_dir_checksum:
checksum = self._get_granular_checksum(path, out)
cache_path = out.cache.tree.hash_to_path_info(checksum).url
else:
cache_path = out.cache_path
return open(cache_path, mode=mode, encoding=encoding)
def exists(self, path): # pylint: disable=arguments-differ
try:
self._find_outs(path, strict=False, recursive=True)
return True
except OutputNotFoundError:
return False
def isdir(self, path): # pylint: disable=arguments-differ
if not self.exists(path):
return False
path_info = PathInfo(os.path.abspath(path))
outs = self._find_outs(path, strict=False, recursive=True)
if len(outs) != 1:
return True
out = outs[0]
if not out.is_dir_checksum:
if out.path_info != path_info:
return True
return False
if out.path_info == path_info:
return True
# for dir checksum, we need to check if this is a file inside the
# directory
try:
self._get_granular_checksum(path_info, out)
return False
except FileNotFoundError:
return True
def isfile(self, path): # pylint: disable=arguments-differ
if not self.exists(path):
return False
return not self.isdir(path)
def _add_dir(self, top, trie, out, download_callback=None, **kwargs):
if not self.fetch and not self.stream:
return
# pull dir cache if needed
dir_cache = out.get_dir_cache(**kwargs)
# pull dir contents if needed
if self.fetch:
if out.changed_cache(filter_info=top):
used_cache = out.get_used_cache(filter_info=top)
downloaded = self.repo.cloud.pull(used_cache, **kwargs)
if download_callback:
download_callback(downloaded)
for entry in dir_cache:
entry_relpath = entry[out.tree.PARAM_RELPATH]
if os.name == "nt":
entry_relpath = entry_relpath.replace("/", os.sep)
path_info = out.path_info / entry_relpath
trie[path_info.parts] = None
def _walk(self, root, trie, topdown=True, **kwargs):
dirs = set()
files = []
out = trie.get(root.parts)
if out and out.is_dir_checksum:
self._add_dir(root, trie, out, **kwargs)
root_len = len(root.parts)
for key, out in trie.iteritems(prefix=root.parts): # noqa: B301
if key == root.parts:
continue
name = key[root_len]
if len(key) > root_len + 1 or (out and out.is_dir_checksum):
dirs.add(name)
continue
files.append(name)
assert topdown
dirs = list(dirs)
yield root.fspath, dirs, files
for dname in dirs:
yield from self._walk(root / dname, trie)
def walk(self, top, topdown=True, onerror=None, **kwargs):
from pygtrie import Trie
assert topdown
if not self.exists(top):
if onerror is not None:
onerror(FileNotFoundError(top))
return
if not self.isdir(top):
if onerror is not None:
onerror(NotADirectoryError(top))
return
root = PathInfo(os.path.abspath(top))
outs = self._find_outs(top, recursive=True, strict=False)
trie = Trie()
for out in outs:
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)
yield from self._walk(root, trie, topdown=topdown, **kwargs)
def isdvc(self, path, **kwargs):
try:
return len(self._find_outs(path, **kwargs)) == 1
except OutputNotFoundError:
pass
return False
def isexec(self, path): # pylint: disable=unused-argument
return False
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_checksum(path_info, out)
return out.checksum
class RepoTree(BaseTree): # pylint:disable=abstract-method
"""DVC + git-tracked files tree.
Args:
repo: DVC or git repo.
Any kwargs will be passed to `DvcTree()`.
"""
def __init__(self, repo, **kwargs):
super().__init__(repo, {"url": repo.root_dir})
if hasattr(repo, "dvc_dir"):
self.dvctree = DvcTree(repo, **kwargs)
else:
# git-only erepo's do not need dvctree
self.dvctree = None
@property
def fetch(self):
if self.dvctree:
return self.dvctree.fetch
return False
@property
def stream(self):
if self.dvctree:
return self.dvctree.stream
return False
def open(
self, path, mode="r", encoding="utf-8", **kwargs
): # pylint: disable=arguments-differ
if "b" in mode:
encoding = None
if self.dvctree and self.dvctree.exists(path):
return self.dvctree.open(
path, mode=mode, encoding=encoding, **kwargs
)
return self.repo.tree.open(path, mode=mode, encoding=encoding)
def exists(
self, path, use_dvcignore=True
): # pylint: disable=arguments-differ
return self.repo.tree.exists(path) or (
self.dvctree and self.dvctree.exists(path)
)
def isdir(self, path): # pylint: disable=arguments-differ
return self.repo.tree.isdir(path) or (
self.dvctree and self.dvctree.isdir(path)
)
def isdvc(self, path, **kwargs):
return self.dvctree is not None and self.dvctree.isdvc(path, **kwargs)
def isfile(self, path): # pylint: disable=arguments-differ
return self.repo.tree.isfile(path) or (
self.dvctree and self.dvctree.isfile(path)
)
def isexec(self, path):
if self.dvctree and self.dvctree.exists(path):
return self.dvctree.isexec(path)
return self.repo.tree.isexec(path)
def stat(self, path):
return self.repo.tree.stat(path)
def _walk_one(self, walk):
try:
root, dirs, files = next(walk)
except StopIteration:
return
yield root, dirs, files
for _ in dirs:
yield from self._walk_one(walk)
def _walk(self, dvc_walk, repo_walk, dvcfiles=False):
try:
_, dvc_dirs, dvc_fnames = next(dvc_walk)
repo_root, repo_dirs, repo_fnames = next(repo_walk)
except StopIteration:
return
# separate subdirs into shared dirs, dvc-only dirs, repo-only dirs
dvc_set = set(dvc_dirs)
repo_set = set(repo_dirs)
dvc_only = list(dvc_set - repo_set)
repo_only = list(repo_set - dvc_set)
shared = list(dvc_set & repo_set)
dirs = shared + dvc_only + repo_only
# merge file lists
files = {
fname
for fname in dvc_fnames + repo_fnames
if dvcfiles or not is_valid_filename(fname)
}
yield repo_root, dirs, list(files)
# set dir order for next recursion level - shared dirs first so that
# next() for both generators recurses into the same shared directory
dvc_dirs[:] = [dirname for dirname in dirs if dirname in dvc_set]
repo_dirs[:] = [dirname for dirname in dirs if dirname in repo_set]
for dirname in dirs:
if dirname in shared:
yield from self._walk(dvc_walk, repo_walk, dvcfiles=dvcfiles)
elif dirname in dvc_set:
yield from self._walk_one(dvc_walk)
elif dirname in repo_set:
yield from self._walk_one(repo_walk)
def walk(
self, top, topdown=True, onerror=None, dvcfiles=False, **kwargs
): # pylint: disable=arguments-differ
"""Walk and merge both DVC and repo trees.
Args:
top: path to walk from
topdown: if True, tree 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
for walked directories.
Any kwargs will be passed into methods used for fetching and/or
streaming DVC outs from remotes.
"""
assert topdown
if not self.exists(top):
if onerror is not None:
onerror(FileNotFoundError(top))
return
if not self.isdir(top):
if onerror is not None:
onerror(NotADirectoryError(top))
return
dvc_exists = self.dvctree and self.dvctree.exists(top)
repo_exists = self.repo.tree.exists(top)
if dvc_exists and not repo_exists:
yield from self.dvctree.walk(
top, topdown=topdown, onerror=onerror, **kwargs
)
return
if repo_exists and not dvc_exists:
yield from self.repo.tree.walk(
top, topdown=topdown, onerror=onerror
)
return
dvc_walk = self.dvctree.walk(top, topdown=topdown, **kwargs)
repo_walk = self.repo.tree.walk(top, topdown=topdown)
yield from self._walk(dvc_walk, repo_walk, dvcfiles=dvcfiles)
def walk_files(self, top, **kwargs): # pylint: disable=arguments-differ
for root, _, files in self.walk(top, **kwargs):
for fname in files:
yield PathInfo(root) / fname
def get_file_hash(self, path_info):
"""Return file checksum for specified path.
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
if self.dvctree and self.dvctree.exists(path_info):
try:
return self.dvctree.get_file_hash(path_info)
except OutputNotFoundError:
pass
return 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)
@property
def hash_jobs(self): # pylint: disable=invalid-overridden-method
return self.repo.tree.hash_jobs
+2
View File
@@ -9,3 +9,5 @@ class Schemes:
GDRIVE = "gdrive"
LOCAL = "local"
OSS = "oss"
WEBDAV = "webdav"
WEBDAVS = "webdavs"
+44 -36
View File
@@ -5,7 +5,6 @@ import os
import shlex
from functools import partial
import yaml
from funcy import cached_property
from pathspec.patterns import GitWildMatchPattern
@@ -20,6 +19,7 @@ from dvc.scm.base import (
)
from dvc.utils import fix_env, is_binary, relpath
from dvc.utils.fs import path_isin
from dvc.utils.serialize import modify_yaml
logger = logging.getLogger(__name__)
@@ -307,7 +307,21 @@ class Git(Base):
os.chmod(hook, 0o777)
def _install_merge_driver(self):
self.repo.git.config("merge.dvc.name", "DVC merge driver")
self.repo.git.config(
"merge.dvc.driver",
(
"dvc git-hook merge-driver "
"--ancestor %O "
"--our %A "
"--their %B "
),
)
def install(self, use_pre_commit_tool=False):
self._install_merge_driver()
if not use_pre_commit_tool:
self._verify_dvc_hooks()
self._install_hook("post-checkout")
@@ -316,41 +330,32 @@ class Git(Base):
return
config_path = os.path.join(self.root_dir, ".pre-commit-config.yaml")
with modify_yaml(config_path) as config:
entry = {
"repo": "https://github.com/iterative/dvc",
"rev": "master",
"hooks": [
{
"id": "dvc-pre-commit",
"language_version": "python3",
"stages": ["commit"],
},
{
"id": "dvc-pre-push",
"language_version": "python3",
"stages": ["push"],
},
{
"id": "dvc-post-checkout",
"language_version": "python3",
"stages": ["post-checkout"],
"always_run": True,
},
],
}
config = {}
if os.path.exists(config_path):
with open(config_path) as fobj:
config = yaml.safe_load(fobj)
entry = {
"repo": "https://github.com/iterative/dvc",
"rev": "master",
"hooks": [
{
"id": "dvc-pre-commit",
"language_version": "python3",
"stages": ["commit"],
},
{
"id": "dvc-pre-push",
"language_version": "python3",
"stages": ["push"],
},
{
"id": "dvc-post-checkout",
"language_version": "python3",
"stages": ["post-checkout"],
"always_run": True,
},
],
}
if entry in config["repos"]:
return
config["repos"].append(entry)
with open(config_path, "w+") as fobj:
yaml.dump(config, fobj)
if entry not in config["repos"]:
config["repos"].append(entry)
def cleanup_ignores(self):
for path in self.ignored_paths:
@@ -393,9 +398,10 @@ class Git(Base):
return self.repo.rev_parse("HEAD").hexsha
def resolve_rev(self, rev):
from git.exc import BadName, GitCommandError
from contextlib import suppress
from git.exc import BadName, GitCommandError
def _resolve_rev(name):
with suppress(BadName, GitCommandError):
try:
@@ -404,6 +410,8 @@ class Git(Base):
except NotImplementedError:
# Fall back to `git rev-parse` for advanced features
return self.repo.git.rev_parse(name)
except ValueError:
raise RevError(f"unknown Git revision '{name}'")
# Resolve across local names
sha = _resolve_rev(rev)
+58 -11
View File
@@ -7,7 +7,7 @@ from funcy import cached_property, project
import dvc.dependency as dependency
import dvc.prompt as prompt
from dvc.exceptions import CheckoutError, DvcException
from dvc.exceptions import CheckoutError, DvcException, MergeError
from dvc.utils import relpath
from . import params
@@ -306,7 +306,12 @@ class Stage(params.StageParams):
@rwlocked(read=["deps"], write=["outs"])
def reproduce(self, interactive=False, **kwargs):
if not (kwargs.get("force", False) or self.changed()):
logger.info("Stage '%s' didn't change, skipping", self.addressing)
if not isinstance(self, PipelineStage) and self.is_data_source:
logger.info("'%s' didn't change, skipping", self.addressing)
else:
logger.info(
"Stage '%s' didn't change, skipping", self.addressing
)
return None
msg = (
@@ -396,7 +401,7 @@ class Stage(params.StageParams):
@staticmethod
def _changed_entries(entries):
return [str(entry) for entry in entries if entry.changed_checksum()]
return [str(entry) for entry in entries if entry.workspace_status()]
def _changed_stage_entry(self):
return f"'md5' of {self} changed."
@@ -437,7 +442,7 @@ class Stage(params.StageParams):
if not no_commit:
self.commit()
def _filter_outs(self, path_info):
def filter_outs(self, path_info):
def _func(o):
return path_info.isin_or_eq(o.path_info)
@@ -446,7 +451,7 @@ class Stage(params.StageParams):
@rwlocked(write=["outs"])
def checkout(self, **kwargs):
stats = defaultdict(list)
for out in self._filter_outs(kwargs.get("filter_info")):
for out in self.filter_outs(kwargs.get("filter_info")):
key, outs = self._checkout(out, **kwargs)
if key:
stats[key].extend(outs)
@@ -464,13 +469,13 @@ class Stage(params.StageParams):
return "failed", exc.target_infos
@rwlocked(read=["deps", "outs"])
def status(self, check_updates=False):
def status(self, check_updates=False, filter_info=None):
ret = []
show_import = self.is_repo_import and check_updates
if not self.frozen or show_import:
self._status_deps(ret)
self._status_outs(ret)
self._status_outs(ret, filter_info=filter_info)
self._status_always_changed(ret)
self._status_stage(ret)
return {self.addressing: ret} if ret else {}
@@ -489,8 +494,9 @@ class Stage(params.StageParams):
if deps_status:
ret.append({"changed deps": deps_status})
def _status_outs(self, ret):
outs_status = self._status(self.outs)
def _status_outs(self, ret, filter_info):
filter_outs = self.filter_outs(filter_info)
outs_status = self._status(filter_outs)
if outs_status:
ret.append({"changed outs": outs_status})
@@ -521,18 +527,56 @@ class Stage(params.StageParams):
def get_all_files_number(self, filter_info=None):
return sum(
out.get_files_number(filter_info)
for out in self._filter_outs(filter_info)
for out in self.filter_outs(filter_info)
)
def get_used_cache(self, *args, **kwargs):
from dvc.cache import NamedCache
cache = NamedCache()
for out in self._filter_outs(kwargs.get("filter_info")):
for out in self.filter_outs(kwargs.get("filter_info")):
cache.update(out.get_used_cache(*args, **kwargs))
return cache
@staticmethod
def _check_can_merge(stage, ancestor_out=None):
if isinstance(stage, PipelineStage):
raise MergeError("unable to auto-merge pipeline stages")
if not stage.is_data_source or stage.deps or len(stage.outs) > 1:
raise MergeError(
"unable to auto-merge DVC-files that weren't "
"created by `dvc add`"
)
if ancestor_out and not stage.outs:
raise MergeError(
"unable to auto-merge DVC-files with deleted outputs"
)
def merge(self, ancestor, other):
assert other
if not other.outs:
return
if not self.outs:
self.outs = other.outs
return
if ancestor:
self._check_can_merge(ancestor)
outs = ancestor.outs
ancestor_out = outs[0] if outs else None
else:
ancestor_out = None
self._check_can_merge(self, ancestor_out)
self._check_can_merge(other, ancestor_out)
self.outs[0].merge(ancestor_out, other.outs[0])
class PipelineStage(Stage):
def __init__(self, *args, name=None, **kwargs):
@@ -572,3 +616,6 @@ class PipelineStage(Stage):
def _changed_stage_entry(self):
return f"'cmd' of {self} has changed."
def merge(self, ancestor, other):
raise NotImplementedError
+4 -6
View File
@@ -2,7 +2,6 @@ import logging
import os
from contextlib import contextmanager
import yaml
from funcy import first
from voluptuous import Invalid
@@ -10,7 +9,7 @@ from dvc.cache.local import _log_exceptions
from dvc.schema import COMPILED_LOCK_FILE_STAGE_SCHEMA
from dvc.utils import dict_sha256, relpath
from dvc.utils.fs import makedirs
from dvc.utils.yaml import dump_yaml
from dvc.utils.serialize import YAMLFileCorruptedError, dump_yaml, load_yaml
from .loader import StageLoader
from .serialize import to_single_stage_lockfile
@@ -54,11 +53,10 @@ class StageCache:
path = self._get_cache_path(key, value)
try:
with open(path) as fobj:
return COMPILED_LOCK_FILE_STAGE_SCHEMA(yaml.safe_load(fobj))
return COMPILED_LOCK_FILE_STAGE_SCHEMA(load_yaml(path))
except FileNotFoundError:
return None
except (yaml.error.YAMLError, Invalid):
except (YAMLFileCorruptedError, Invalid):
logger.warning("corrupted cache file '%s'.", relpath(path))
os.unlink(path)
return None
@@ -80,7 +78,7 @@ class StageCache:
return None
def _create_stage(self, cache, wdir=None):
from . import create_stage, PipelineStage
from . import PipelineStage, create_stage
stage = create_stage(
PipelineStage,
+2 -1
View File
@@ -6,8 +6,9 @@ from funcy import decorator
@decorator
def rwlocked(call, read=None, write=None):
import sys
from dvc.rwlock import rwlock
from dvc.dependency.repo import RepoDependency
from dvc.rwlock import rwlock
if read is None:
read = []
+3 -1
View File
@@ -42,7 +42,9 @@ class StageLoader(Mapping):
if isinstance(item, dependency.ParamsDependency):
item.fill_values(get_in(lock_data, [stage.PARAM_PARAMS, path]))
continue
item.checksum = get_in(checksums, [key, path, item.checksum_type])
item.info = get_in(checksums, [key, path], {})
item.info = item.info.copy()
item.info.pop("path", None)
@classmethod
def load_stage(cls, dvcfile, name, stage_data, lock_data=None):
+2 -7
View File
@@ -8,7 +8,7 @@ from funcy import post_processing
from dvc.dependency import ParamsDependency
from dvc.output import BaseOutput
from dvc.utils.collections import apply_diff
from dvc.utils.yaml import parse_yaml_for_update
from dvc.utils.serialize import parse_yaml_for_update
from .params import StageParams
from .utils import resolve_wdir, split_params_deps
@@ -138,12 +138,7 @@ def to_single_stage_lockfile(stage: "Stage") -> dict:
params, deps = split_params_deps(stage)
deps, outs = [
[
OrderedDict(
[
(PARAM_PATH, item.def_path),
(item.checksum_type, item.checksum),
]
)
OrderedDict([(PARAM_PATH, item.def_path), *item.info.items()])
for item in sort_by_path(items)
]
for items in [deps, stage.outs]
+2 -1
View File
@@ -110,9 +110,10 @@ def check_circular_dependency(stage):
def check_duplicated_arguments(stage):
from dvc.exceptions import ArgumentDuplicationError
from collections import Counter
from dvc.exceptions import ArgumentDuplicationError
path_counts = Counter(edge.path_info for edge in stage.deps + stage.outs)
for path, occurrence in path_counts.items():
+3 -2
View File
@@ -119,13 +119,14 @@ class System:
@staticmethod
def _getdirinfo(path):
from collections import namedtuple
from win32file import ( # pylint: disable=import-error
CreateFileW,
GetFileInformationByHandle,
FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT,
FILE_SHARE_READ,
OPEN_EXISTING,
CreateFileW,
GetFileInformationByHandle,
)
# NOTE: use FILE_FLAG_OPEN_REPARSE_POINT to open symlink itself and not
+4
View File
@@ -11,6 +11,8 @@ from .local import LocalTree
from .oss import OSSTree
from .s3 import S3Tree
from .ssh import SSHTree
from .webdav import WebDAVTree
from .webdavs import WebDAVSTree
TREES = [
AzureTree,
@@ -22,6 +24,8 @@ TREES = [
S3Tree,
SSHTree,
OSSTree,
WebDAVTree,
WebDAVSTree,
# NOTE: LocalTree is the default
]
+68
View File
@@ -0,0 +1,68 @@
from dataclasses import dataclass, field
from typing import List
from dvc.output import BaseOutput
from dvc.path_info import PathInfo
@dataclass
class Metadata:
"""
Container for storing metadata for a given path, similar in spirit to
`os.stat_result`.
"""
# required field
path_info: PathInfo
# computed fields
is_output: bool = field(init=False, default=False) # is it an output?
# is the path part of an output?
part_of_output: bool = field(init=False, default=False)
# does the path contain outputs?
contains_outputs: bool = field(init=False, default=False)
# is the path tracked by dvc? equivalent to (is_output or part_of_output)
is_dvc: bool = field(init=False, default=False)
# does dvc has any output in that path or inside it? equiv. to bool(outs)
output_exists: bool = field(init=False)
# optional fields
isdir: bool = False # is it a directory?
is_exec: bool = False # is it an executable?
outs: List[BaseOutput] = field(default_factory=list) # list of outputs
def __post_init__(self):
self.output_exists = bool(self.outs)
if not self.output_exists:
return
# it can contain multiple outputs
if len(self.outs) > 1:
self.contains_outputs = True
else:
out = self.outs[0]
# or, the path itself could be an output
self.is_output = self.path_info == out.path_info
# or, a directory must have an output somewhere deep inside it
if not self.is_output:
self.contains_outputs = out.path_info.isin(self.path_info)
# or, the path could be a part of an output, i.e. inside of an output
self.part_of_output = not self.is_output and not self.contains_outputs
self.is_dvc = self.is_output or self.part_of_output
# if it contains outputs, it must be a dir
self.isdir = self.contains_outputs
@property
def isfile(self):
# self.isdir might require additional computation, especially if it's
# a dir that's inside a dvc-tracked dir
# since, `Metadata()` does not support being used as null (i.e. when
# path does not exist), `isfile` becomes `isdir`'s complement.
return not self.isdir
def __str__(self):
return f"Metadata: {self.path_info}"
+80 -60
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from funcy import cached_property, wrap_prop
from dvc.hash_info import HashInfo
from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
@@ -35,16 +36,20 @@ class AzureTree(BaseTree):
container = self._az_config.get("storage", "container_name", None)
self.path_info = self.PATH_CLS(f"azure://{container}")
self._conn_kwargs = {
opt: config.get(opt) or self._az_config.get("storage", opt, None)
for opt in ["connection_string", "sas_token"]
}
self._conn_kwargs["account_name"] = self._az_config.get(
"storage", "account", None
)
self._conn_kwargs["account_key"] = self._az_config.get(
"storage", "key", None
self._conn_str = config.get(
"connection_string"
) or self._az_config.get("storage", "connection_string", None)
self._account_url = None
if not self._conn_str:
name = self._az_config.get("storage", "account", None)
self._account_url = f"https://{name}.blob.core.windows.net"
self._credential = config.get("sas_token") or self._az_config.get(
"storage", "sas_token", None
)
if not self._credential:
self._credential = self._az_config.get("storage", "key", None)
@cached_property
def _az_config(self):
@@ -62,64 +67,71 @@ class AzureTree(BaseTree):
@cached_property
def blob_service(self):
# pylint: disable=no-name-in-module
from azure.storage.blob import BlockBlobService
from azure.common import AzureMissingResourceHttpError
from azure.core.exceptions import ResourceNotFoundError
from azure.storage.blob import BlobServiceClient
logger.debug(f"URL {self.path_info}")
logger.debug(f"Connection options {self._conn_kwargs}")
blob_service = BlockBlobService(**self._conn_kwargs)
logger.debug(f"Container name {self.path_info.bucket}")
try: # verify that container exists
blob_service.list_blobs(
self.path_info.bucket, delimiter="/", num_results=1
if self._conn_str:
logger.debug(f"Using connection string '{self._conn_str}'")
blob_service = BlobServiceClient.from_connection_string(
self._conn_str, credential=self._credential
)
except AzureMissingResourceHttpError:
blob_service.create_container(self.path_info.bucket)
else:
logger.debug(f"Using account url '{self._account_url}'")
blob_service = BlobServiceClient(
self._account_url, credential=self._credential
)
logger.debug(f"Container name {self.path_info.bucket}")
container_client = blob_service.get_container_client(
self.path_info.bucket
)
try: # verify that container exists
container_client.get_container_properties()
except ResourceNotFoundError:
container_client.create_container()
return blob_service
def get_etag(self, path_info):
etag = self.blob_service.get_blob_properties(
blob_client = self.blob_service.get_blob_client(
path_info.bucket, path_info.path
).properties.etag
)
etag = blob_client.get_blob_properties().etag
return etag.strip('"')
def _generate_download_url(self, path_info, expires=3600):
from azure.storage.blob import ( # pylint:disable=no-name-in-module
BlobPermissions,
BlobSasPermissions,
generate_blob_sas,
)
expires_at = datetime.utcnow() + timedelta(seconds=expires)
sas_token = self.blob_service.generate_blob_shared_access_signature(
path_info.bucket,
path_info.path,
permission=BlobPermissions.READ,
blob_client = self.blob_service.get_blob_client(
path_info.bucket, path_info.path
)
sas_token = generate_blob_sas(
blob_client.account_name,
blob_client.container_name,
blob_client.blob_name,
account_key=blob_client.credential.account_key,
permission=BlobSasPermissions(read=True),
expiry=expires_at,
)
download_url = self.blob_service.make_blob_url(
path_info.bucket, path_info.path, sas_token=sas_token
)
return download_url
return blob_client.url + "?" + sas_token
def exists(self, path_info, use_dvcignore=True):
paths = self._list_paths(path_info.bucket, path_info.path)
return any(path_info.path == path for path in paths)
def _list_paths(self, bucket, prefix):
blob_service = self.blob_service
next_marker = None
while True:
blobs = blob_service.list_blobs(
bucket, prefix=prefix, marker=next_marker
)
for blob in blobs:
yield blob.name
if not blobs.next_marker:
break
next_marker = blobs.next_marker
container_client = self.blob_service.get_container_client(bucket)
for blob in container_client.list_blobs(name_starts_with=prefix):
yield blob.name
def walk_files(self, path_info, **kwargs):
if not kwargs.pop("prefix", False):
@@ -137,29 +149,37 @@ class AzureTree(BaseTree):
raise NotImplementedError
logger.debug(f"Removing {path_info}")
self.blob_service.delete_blob(path_info.bucket, path_info.path)
self.blob_service.get_blob_client(
path_info.bucket, path_info.path
).delete_blob()
def get_file_hash(self, path_info):
return self.get_etag(path_info)
return HashInfo(self.PARAM_CHECKSUM, self.get_etag(path_info))
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
self.blob_service.create_blob_from_path(
to_info.bucket,
to_info.path,
from_file,
progress_callback=pbar.update_to,
)
blob_client = self.blob_service.get_blob_client(
to_info.bucket, 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:
blob_client.upload_blob(wrapped, overwrite=True)
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
):
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
self.blob_service.get_blob_to_path(
from_info.bucket,
from_info.path,
to_file,
progress_callback=pbar.update_to,
)
blob_client = self.blob_service.get_blob_client(
from_info.bucket, from_info.path
)
total = blob_client.get_blob_properties().size
stream = blob_client.download_blob()
with open(to_file, "wb") as fobj:
with Tqdm.wrapattr(
fobj, "write", desc=name, total=total, disable=no_progress_bar
) as wrapped:
stream.readinto(wrapped)
+39 -44
View File
@@ -15,6 +15,7 @@ from dvc.exceptions import (
DvcIgnoreInCollectedDirError,
RemoteCacheRequiredError,
)
from dvc.hash_info import HashInfo
from dvc.ignore import DvcIgnore
from dvc.path_info import PathInfo, URLInfo
from dvc.progress import Tqdm
@@ -236,22 +237,17 @@ class BaseTree:
return False
return hash_.endswith(cls.CHECKSUM_DIR_SUFFIX)
def get_hash(self, path_info, tree=None, **kwargs):
def get_hash(self, path_info, **kwargs):
assert path_info and (
isinstance(path_info, str) or path_info.scheme == self.scheme
)
if not tree:
tree = self
if not tree.exists(path_info):
if not self.exists(path_info):
return None
if tree == self:
# pylint: disable=assignment-from-none
hash_ = self.state.get(path_info)
else:
hash_ = None
# pylint: disable=assignment-from-none
hash_ = 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
@@ -265,27 +261,27 @@ class BaseTree:
hash_ = None
if hash_:
return hash_
return HashInfo(self.PARAM_CHECKSUM, hash_)
if tree.isdir(path_info):
hash_ = self.get_dir_hash(path_info, tree, **kwargs)
if self.isdir(path_info):
hash_info = self.get_dir_hash(path_info, **kwargs)
else:
hash_ = tree.get_file_hash(path_info)
hash_info = self.get_file_hash(path_info)
if hash_ and self.exists(path_info):
self.state.save(path_info, hash_)
if hash_info and self.exists(path_info):
self.state.save(path_info, hash_info.value)
return hash_
return hash_info
def get_file_hash(self, path_info):
raise NotImplementedError
def get_dir_hash(self, path_info, tree, **kwargs):
def get_dir_hash(self, path_info, **kwargs):
if not self.cache:
raise RemoteCacheRequiredError(path_info)
dir_info = self._collect_dir(path_info, tree, **kwargs)
return self._save_dir_info(dir_info, path_info)
dir_info = self._collect_dir(path_info, **kwargs)
return self.save_dir_info(dir_info)
def hash_to_path_info(self, hash_):
return self.path_info / hash_[0:2] / hash_[2:]
@@ -298,29 +294,26 @@ class BaseTree:
return "".join(parts)
def save_info(self, path_info, tree=None, **kwargs):
return {
self.PARAM_CHECKSUM: self.get_hash(path_info, tree=tree, **kwargs)
}
def save_info(self, path_info, **kwargs):
hash_info = self.get_hash(path_info, **kwargs)
return {hash_info.name: hash_info.value}
@staticmethod
def _calculate_hashes(file_infos, tree):
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(tree.get_file_hash)
with ThreadPoolExecutor(max_workers=tree.hash_jobs) as executor:
tasks = executor.map(worker, file_infos)
hashes = dict(zip(file_infos, tasks))
return hashes
worker = pbar.wrap_fn(self.get_file_hash)
with ThreadPoolExecutor(max_workers=self.hash_jobs) as executor:
hashes = (hi.value for hi in executor.map(worker, file_infos))
return dict(zip(file_infos, hashes))
def _collect_dir(self, path_info, tree, **kwargs):
def _collect_dir(self, path_info, **kwargs):
file_infos = set()
for fname in tree.walk_files(path_info, **kwargs):
for fname in self.walk_files(path_info, **kwargs):
if DvcIgnore.DVCIGNORE_FILE == fname.name:
raise DvcIgnoreInCollectedDirError(fname.parent)
@@ -329,7 +322,7 @@ class BaseTree:
hashes = {fi: self.state.get(fi) for fi in file_infos}
not_in_state = {fi for fi, hash_ in hashes.items() if hash_ is None}
new_hashes = self._calculate_hashes(not_in_state, tree)
new_hashes = self._calculate_hashes(not_in_state)
hashes.update(new_hashes)
result = [
@@ -351,22 +344,23 @@ class BaseTree:
# Sorting the list by path to ensure reproducibility
return sorted(result, key=itemgetter(self.PARAM_RELPATH))
def _save_dir_info(self, dir_info, path_info):
hash_, tmp_info = self._get_dir_info_hash(dir_info)
new_info = self.cache.tree.hash_to_path_info(hash_)
if self.cache.changed_cache_file(hash_):
def save_dir_info(self, dir_info):
hash_info, tmp_info = self._get_dir_info_hash(dir_info)
new_info = self.cache.tree.hash_to_path_info(hash_info.value)
if self.cache.changed_cache_file(hash_info.value):
self.cache.tree.makedirs(new_info.parent)
self.cache.tree.move(
tmp_info, new_info, mode=self.cache.CACHE_MODE
)
if self.exists(path_info):
self.state.save(path_info, hash_)
self.state.save(new_info, hash_)
self.state.save(new_info, hash_info.value)
return hash_
return hash_info
def _get_dir_info_hash(self, dir_info):
# Sorting the list by path to ensure reproducibility
dir_info = sorted(dir_info, key=itemgetter(self.PARAM_RELPATH))
tmp = tempfile.NamedTemporaryFile(delete=False).name
with open(tmp, "w+") as fobj:
json.dump(dir_info, fobj, sort_keys=True)
@@ -376,8 +370,9 @@ class BaseTree:
to_info = tree.path_info / tmp_fname("")
tree.upload(from_info, to_info, no_progress_bar=True)
hash_ = tree.get_file_hash(to_info) + self.CHECKSUM_DIR_SUFFIX
return hash_, to_info
hash_info = tree.get_file_hash(to_info)
hash_info.value += self.CHECKSUM_DIR_SUFFIX
return hash_info, to_info
def upload(self, from_info, to_info, name=None, no_progress_bar=False):
if not hasattr(self, "_upload"):
+273
View File
@@ -0,0 +1,273 @@
import logging
import os
from dvc.exceptions import OutputNotFoundError
from dvc.hash_info import HashInfo
from dvc.path_info import PathInfo
from ._metadata import Metadata
from .base import BaseTree, RemoteActionNotImplemented
logger = logging.getLogger(__name__)
class DvcTree(BaseTree): # pylint:disable=abstract-method
"""DVC repo tree.
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):
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)
def _is_cached(out):
return out.use_cache
outs = list(filter(_is_cached, outs))
if not outs:
raise OutputNotFoundError(path, self.repo)
return outs
def _get_granular_checksum(self, path, out, remote=None):
assert isinstance(path, PathInfo)
if not self.fetch and not self.stream:
raise FileNotFoundError
dir_cache = out.get_dir_cache(remote=remote)
for entry in dir_cache:
entry_relpath = entry[out.tree.PARAM_RELPATH]
if os.name == "nt":
entry_relpath = entry_relpath.replace("/", os.sep)
if path == out.path_info / entry_relpath:
return entry[out.tree.PARAM_CHECKSUM]
raise FileNotFoundError
def open(
self, path, mode="r", encoding="utf-8", remote=None
): # pylint: disable=arguments-differ
try:
outs = self._find_outs(path, strict=False)
except OutputNotFoundError as exc:
raise FileNotFoundError from exc
# NOTE: this handles both dirty and checkout-ed out at the same time
if self.repo.tree.exists(path):
return self.repo.tree.open(path, mode=mode, encoding=encoding)
if len(outs) != 1 or (
outs[0].is_dir_checksum and path == outs[0].path_info
):
raise IsADirectoryError
out = outs[0]
if out.changed_cache(filter_info=path):
if not self.fetch and not self.stream:
raise FileNotFoundError
remote_obj = self.repo.cloud.get_remote(remote)
if self.stream:
if out.is_dir_checksum:
checksum = self._get_granular_checksum(path, out)
else:
checksum = out.checksum
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)
if out.is_dir_checksum:
checksum = self._get_granular_checksum(path, out)
cache_path = out.cache.tree.hash_to_path_info(checksum).url
else:
cache_path = out.cache_path
return open(cache_path, mode=mode, encoding=encoding)
def exists(self, path): # pylint: disable=arguments-differ
try:
self.metadata(path)
return True
except OutputNotFoundError:
return False
def isdir(self, path): # pylint: disable=arguments-differ
try:
meta = self.metadata(path)
return meta.isdir
except OutputNotFoundError:
return False
def check_isdir(self, path_info, outs):
if len(outs) != 1:
return True
out = outs[0]
if not out.is_dir_checksum:
return out.path_info != path_info
if out.path_info == path_info:
return True
# for dir checksum, we need to check if this is a file inside the
# directory
try:
self._get_granular_checksum(path_info, out)
return False
except FileNotFoundError:
return True
def isfile(self, path): # pylint: disable=arguments-differ
try:
meta = self.metadata(path)
return meta.isfile
except OutputNotFoundError:
return False
def _fetch_dir(
self, out, filter_info=None, download_callback=None, **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)
def _add_dir(self, top, trie, out, **kwargs):
if not self.fetch and not self.stream:
return
self._fetch_dir(out, filter_info=top, **kwargs)
for entry in out.dir_cache:
entry_relpath = entry[out.tree.PARAM_RELPATH]
if os.name == "nt":
entry_relpath = entry_relpath.replace("/", os.sep)
path_info = out.path_info / entry_relpath
trie[path_info.parts] = None
def _walk(self, root, trie, topdown=True, **kwargs):
dirs = set()
files = []
out = trie.get(root.parts)
if out and out.is_dir_checksum:
self._add_dir(root, trie, out, **kwargs)
root_len = len(root.parts)
for key, out in trie.iteritems(prefix=root.parts): # noqa: B301
if key == root.parts:
continue
name = key[root_len]
if len(key) > root_len + 1 or (out and out.is_dir_checksum):
dirs.add(name)
continue
files.append(name)
assert topdown
dirs = list(dirs)
yield root.fspath, dirs, files
for dname in dirs:
yield from self._walk(root / dname, trie)
def walk(self, top, topdown=True, onerror=None, **kwargs):
from pygtrie import Trie
assert topdown
root = PathInfo(os.path.abspath(top))
try:
meta = self.metadata(root)
except OutputNotFoundError:
if onerror is not None:
onerror(FileNotFoundError(top))
return
if not meta.isdir:
if onerror is not None:
onerror(NotADirectoryError(top))
return
trie = Trie()
for out in meta.outs:
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)
yield from self._walk(root, trie, topdown=topdown, **kwargs)
def walk_files(self, path_info, **kwargs):
for root, _, files in self.walk(path_info):
for fname in files:
# NOTE: os.path.join is ~5.5 times slower
yield PathInfo(f"{root}{os.sep}{fname}")
def isdvc(self, path, recursive=False, strict=True):
try:
meta = self.metadata(path)
except OutputNotFoundError:
return False
recurse = recursive or not strict
return meta.output_exists if recurse else meta.is_output
def isexec(self, path): # 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 HashInfo(out.tree.PARAM_CHECKSUM, out.checksum)
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 HashInfo(
out.tree.PARAM_CHECKSUM,
self._get_granular_checksum(path_info, out),
)
return HashInfo(out.tree.PARAM_CHECKSUM, out.checksum)
def metadata(self, path_info):
path_info = PathInfo(os.path.abspath(path_info))
outs = self._find_outs(path_info, strict=False, recursive=True)
meta = Metadata(path_info=path_info, outs=outs)
meta.isdir = meta.isdir or self.check_isdir(meta.path_info, meta.outs)
return meta
+56 -33
View File
@@ -1,13 +1,13 @@
import errno
import io
import os
import stat
from functools import lru_cache
from funcy import cached_property
from dvc.exceptions import DvcException
from dvc.tree.base import BaseTree
from dvc.utils import relpath
from dvc.utils import is_exec, relpath
# see git-fast-import(1)
GIT_MODE_DIR = 0o40000
@@ -21,6 +21,30 @@ def _item_basename(item):
return os.path.basename(item.path)
def _is_tree_and_contains(obj, path):
if obj.mode != GIT_MODE_DIR:
return False
# see https://github.com/gitpython-developers/GitPython/issues/851
# `return (i in tree)` doesn't work so here is a workaround:
for item in obj:
if _item_basename(item) == path:
return True
return False
# NOTE: small lru cache covers sequential exists() calls
@lru_cache(maxsize=1)
def _get_obj(tree, path):
if not path or path == ".":
return tree
for i in path.split(os.sep):
if not _is_tree_and_contains(tree, i):
# there is no tree for specified path
return None
tree = tree[i]
return tree
class GitTree(BaseTree): # pylint:disable=abstract-method
"""Proxies the repo file access methods to Git objects"""
@@ -84,13 +108,15 @@ class GitTree(BaseTree): # pylint:disable=abstract-method
path
) and not self.dvcignore.is_ignored_dir(path)
def isdir(self, path): # pylint: disable=arguments-differ
def isdir(
self, path, use_dvcignore=True
): # pylint: disable=arguments-differ
obj = self._git_object_by_path(path)
if obj is None:
return False
if obj.mode != GIT_MODE_DIR:
return False
return not self.dvcignore.is_ignored_dir(path)
return not (use_dvcignore and self.dvcignore.is_ignored_dir(path))
def isfile(self, path): # pylint: disable=arguments-differ
obj = self._git_object_by_path(path)
@@ -101,27 +127,12 @@ class GitTree(BaseTree): # pylint:disable=abstract-method
return False
return not self.dvcignore.is_ignored_file(path)
@staticmethod
def _is_tree_and_contains(obj, path):
if obj.mode != GIT_MODE_DIR:
return False
# see https://github.com/gitpython-developers/GitPython/issues/851
# `return (i in tree)` doesn't work so here is a workaround:
for item in obj:
if _item_basename(item) == path:
return True
return False
def _git_object_by_path(self, path):
@cached_property
def _tree(self):
import git
path = relpath(os.path.realpath(path), self.git.working_dir)
if path.split(os.sep, 1)[0] == "..":
# path points outside of git repository
return None
try:
tree = self.git.tree(self.rev)
return self.git.tree(self.rev)
except git.exc.BadName as exc: # pylint: disable=no-member
raise DvcException(
"revision '{}' not found in Git '{}'".format(
@@ -129,14 +140,13 @@ class GitTree(BaseTree): # pylint:disable=abstract-method
)
) from exc
if not path or path == ".":
return tree
for i in path.split(os.sep):
if not self._is_tree_and_contains(tree, i):
# there is no tree for specified path
return None
tree = tree[i]
return tree
def _git_object_by_path(self, path):
path = relpath(os.path.realpath(path), self.git.working_dir)
if path.split(os.sep, 1)[0] == "..":
# path points outside of git repository
return None
return _get_obj(self._tree, path)
def _walk(self, tree, topdown=True):
dirs, nondirs = [], []
@@ -156,7 +166,14 @@ class GitTree(BaseTree): # pylint:disable=abstract-method
if not topdown:
yield os.path.normpath(tree.abspath), dirs, nondirs
def walk(self, top, topdown=True, onerror=None, use_dvcignore=True):
def walk(
self,
top,
topdown=True,
onerror=None,
use_dvcignore=True,
ignore_subrepos=True,
):
"""Directory tree generator.
See `os.walk` for the docs. Differences:
@@ -176,7 +193,10 @@ class GitTree(BaseTree): # pylint:disable=abstract-method
for root, dirs, files in self._walk(tree, topdown=topdown):
if use_dvcignore:
dirs[:], files[:] = self.dvcignore(
os.path.abspath(root), dirs, files
os.path.abspath(root),
dirs,
files,
ignore_subrepos=ignore_subrepos,
)
yield root, dirs, files
@@ -185,7 +205,7 @@ class GitTree(BaseTree): # pylint:disable=abstract-method
return False
mode = self.stat(path).st_mode
return mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return is_exec(mode)
def stat(self, path):
import git
@@ -228,3 +248,6 @@ class GitTree(BaseTree): # pylint:disable=abstract-method
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)
+6 -3
View File
@@ -7,6 +7,7 @@ from functools import wraps
from funcy import cached_property, wrap_prop
from dvc.exceptions import DvcException
from dvc.hash_info import HashInfo
from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
@@ -28,7 +29,6 @@ def dynamic_chunk_size(func):
# Note: must be multiple of 256K.
#
# [#2572]: https://github.com/iterative/dvc/issues/2572
# skipcq: PYL-W0212
multiplier = 40
while True:
@@ -190,11 +190,14 @@ class GSTree(BaseTree):
path = path_info.path
blob = self.gs.bucket(bucket).get_blob(path)
if not blob:
return None
return HashInfo(self.PARAM_CHECKSUM, None)
b64_md5 = blob.md5_hash
md5 = base64.b64decode(b64_md5)
return codecs.getencoder("hex")(md5)[0].decode("utf-8")
return HashInfo(
self.PARAM_CHECKSUM,
codecs.getencoder("hex")(md5)[0].decode("utf-8"),
)
def _upload(self, from_file, to_info, name=None, no_progress_bar=False):
bucket = self.gs.bucket(to_info.bucket)
+12 -1
View File
@@ -7,6 +7,7 @@ from collections import deque
from contextlib import closing, contextmanager
from urllib.parse import urlparse
from dvc.hash_info import HashInfo
from dvc.scheme import Schemes
from dvc.utils import fix_env, tmp_fname
@@ -130,6 +131,14 @@ class HDFSTree(BaseTree):
self.remove(tmp_info)
raise
def isfile(self, path_info):
with self.hdfs(path_info) as hdfs:
return hdfs.isfile(path_info.path)
def isdir(self, path_info):
with self.hdfs(path_info) as hdfs:
return hdfs.isdir(path_info.path)
def hadoop_fs(self, cmd, user=None):
cmd = "hadoop fs -" + cmd
if user:
@@ -167,7 +176,9 @@ class HDFSTree(BaseTree):
stdout = self.hadoop_fs(
f"checksum {path_info.url}", user=path_info.user
)
return self._group(regex, stdout, "checksum")
return HashInfo(
self.PARAM_CHECKSUM, self._group(regex, stdout, "checksum")
)
def _upload(self, from_file, to_info, **_kwargs):
with self.hdfs(to_info) as hdfs:
+19 -9
View File
@@ -6,6 +6,7 @@ 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
@@ -122,12 +123,27 @@ class HTTPTree(BaseTree): # pylint:disable=abstract-method
except requests.exceptions.RequestException:
raise DvcException(f"could not perform a {method} request")
def _head(self, url):
response = self.request("HEAD", url)
if response.ok:
return response
# Sometimes servers are configured to forbid HEAD requests
# Context: https://github.com/iterative/dvc/issues/4131
with self.request("GET", url, stream=True) as r:
if r.ok:
return r
return response
def exists(self, path_info, use_dvcignore=True):
return bool(self.request("HEAD", path_info.url))
return bool(self._head(path_info.url))
def get_file_hash(self, path_info):
url = path_info.url
headers = self.request("HEAD", url).headers
headers = self._head(url).headers
etag = headers.get("ETag") or headers.get("Content-MD5")
if not etag:
@@ -136,13 +152,7 @@ class HTTPTree(BaseTree): # pylint:disable=abstract-method
"Content-MD5 header for '{url}'".format(url=url)
)
if etag.startswith("W/"):
raise DvcException(
"Weak ETags are not supported."
" (Etag: '{etag}', URL: '{url}')".format(etag=etag, url=url)
)
return etag
return HashInfo(self.PARAM_CHECKSUM, etag)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
response = self.request("GET", from_info.url, stream=True)
+24 -9
View File
@@ -7,10 +7,11 @@ 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, relpath, tmp_fname
from dvc.utils import file_md5, is_exec, relpath, tmp_fname
from dvc.utils.fs import (
copy_fobj_to_file,
copyfile,
@@ -86,18 +87,26 @@ class LocalTree(BaseTree):
return not self.dvcignore.is_ignored_file(path_info)
def isdir(self, path_info):
def isdir(
self, path_info, use_dvcignore=True
): # pylint: disable=arguments-differ
if not os.path.isdir(path_info):
return False
return not self.dvcignore.is_ignored_dir(path_info)
return not (use_dvcignore and self.dvcignore.is_ignored_dir(path_info))
def iscopy(self, path_info):
return not (
System.is_symlink(path_info) or System.is_hardlink(path_info)
)
def walk(self, top, topdown=True, onerror=None, use_dvcignore=True):
def walk(
self,
top,
topdown=True,
onerror=None,
use_dvcignore=True,
ignore_subrepos=True,
):
"""Directory tree generator.
See `os.walk` for the docs. Differences:
@@ -108,7 +117,10 @@ class LocalTree(BaseTree):
):
if use_dvcignore:
dirs[:], files[:] = self.dvcignore(
os.path.abspath(root), dirs, files
os.path.abspath(root),
dirs,
files,
ignore_subrepos=ignore_subrepos,
)
yield os.path.normpath(root), dirs, files
@@ -146,7 +158,7 @@ class LocalTree(BaseTree):
def isexec(self, path):
mode = os.stat(path).st_mode
return mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return is_exec(mode)
@staticmethod
def stat(path):
@@ -298,7 +310,7 @@ class LocalTree(BaseTree):
return stat.S_IMODE(mode) == self.CACHE_MODE
def get_file_hash(self, path_info):
return file_md5(path_info)[0]
return HashInfo(self.PARAM_CHECKSUM, file_md5(path_info)[0])
@staticmethod
def getsize(path_info):
@@ -315,7 +327,7 @@ class LocalTree(BaseTree):
)
self.protect(tmp_file)
os.rename(tmp_file, to_info)
os.replace(tmp_file, to_info)
@staticmethod
def _download(
@@ -346,3 +358,6 @@ class LocalTree(BaseTree):
info = self.hash_to_path_info(hash_)
path_info = info.with_name(info.name + self.UNPACKED_DIR_SUFFIX)
self.remove(path_info)
def _reset(self):
return self.__dict__.pop("dvcignore", None)
+387
View File
@@ -0,0 +1,387 @@
import logging
import os
import stat
import threading
from contextlib import suppress
from itertools import takewhile
from typing import TYPE_CHECKING, Callable, Optional, Tuple, Union
from funcy import wrap_with
from pygtrie import StringTrie
from dvc.dvcfile import is_valid_filename
from dvc.exceptions import OutputNotFoundError
from dvc.hash_info import HashInfo
from dvc.path_info import PathInfo
from dvc.utils import file_md5, is_exec
from dvc.utils.fs import copy_fobj_to_file, makedirs
from ._metadata import Metadata
from .base import BaseTree
from .dvc import DvcTree
if TYPE_CHECKING:
from dvc.repo import Repo
from .git import GitTree
from .local import LocalTree
logger = logging.getLogger(__name__)
class RepoTree(BaseTree): # pylint:disable=abstract-method
"""DVC + git-tracked files tree.
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()`.
"""
scheme = "local"
PARAM_CHECKSUM = "md5"
def __init__(
self,
repo,
subrepos=False,
repo_factory: Callable[[str], "Repo"] = None,
**kwargs
):
super().__init__(repo, {"url": repo.root_dir})
if not repo_factory:
from dvc.repo import Repo
self.repo_factory = Repo
else:
self.repo_factory = repo_factory
self._main_repo = repo
self.root_dir = repo.root_dir
self._traverse_subrepos = subrepos
self._subrepos_trie = StringTrie(separator=os.sep)
"""Keeps track of each and every path with the corresponding repo."""
self._subrepos_trie[self.root_dir] = repo
self._dvctrees = {}
"""Keep a dvctree instance of each repo."""
self._dvctree_configs = kwargs
if hasattr(repo, "dvc_dir"):
self._dvctrees[repo.root_dir] = DvcTree(repo, **kwargs)
def _get_repo(self, path) -> Optional["Repo"]:
"""Returns repo that the path falls in, using prefix.
If the path is already tracked/collected, it just returns the repo.
Otherwise, it collects the repos that might be in the path's parents
and then returns the appropriate one.
"""
repo = self._subrepos_trie.get(path)
if repo:
return repo
prefix, repo = self._subrepos_trie.longest_prefix(path)
if not prefix:
return None
parents = (parent.fspath for parent in PathInfo(path).parents)
dirs = [path] + list(takewhile(lambda p: p != prefix, parents))
dirs.reverse()
self._update(dirs, starting_repo=repo)
return self._subrepos_trie.get(path)
@wrap_with(threading.Lock())
def _update(self, dirs, starting_repo):
"""Checks for subrepo in directories and updates them."""
repo = starting_repo
for d in dirs:
if self._is_dvc_repo(d):
repo = self.repo_factory(d)
self._dvctrees[repo.root_dir] = DvcTree(
repo, **self._dvctree_configs
)
self._subrepos_trie[d] = repo
def _is_dvc_repo(self, dir_path):
"""Check if the directory is a dvc repo."""
if not self._traverse_subrepos:
return False
from dvc.repo import Repo
repo_path = os.path.join(dir_path, Repo.DVC_DIR)
# dvcignore will ignore subrepos, therefore using `use_dvcignore=False`
return self._main_repo.tree.isdir(repo_path, use_dvcignore=False)
def _get_tree_pair(
self, path
) -> Tuple[Union["GitTree", "LocalTree"], DvcTree]:
"""
Returns a pair of trees based on repo the path falls in, using prefix.
"""
path = os.path.abspath(path)
# fallback to the top-level repo if repo was not found
# 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
def open(
self, path, mode="r", encoding="utf-8", **kwargs
): # pylint: disable=arguments-differ
if "b" in mode:
encoding = None
tree, dvc_tree = self._get_tree_pair(path)
if dvc_tree and dvc_tree.exists(path):
return dvc_tree.open(path, mode=mode, encoding=encoding, **kwargs)
return tree.open(path, mode=mode, encoding=encoding)
def exists(
self, path, use_dvcignore=True
): # pylint: disable=arguments-differ
tree, dvc_tree = self._get_tree_pair(path)
return tree.exists(path) or (dvc_tree and dvc_tree.exists(path))
def isdir(self, path): # pylint: disable=arguments-differ
tree, dvc_tree = self._get_tree_pair(path)
return tree.isdir(path) or (dvc_tree and dvc_tree.isdir(path))
def isdvc(self, path, **kwargs):
_, dvc_tree = self._get_tree_pair(path)
return dvc_tree is not None and dvc_tree.isdvc(path, **kwargs)
def isfile(self, path): # pylint: disable=arguments-differ
tree, dvc_tree = self._get_tree_pair(path)
return tree.isfile(path) or (dvc_tree and dvc_tree.isfile(path))
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 stat(self, path):
tree, _ = self._get_tree_pair(path)
return tree.stat(path)
def _dvc_walk(self, walk):
try:
root, dirs, files = next(walk)
except StopIteration:
return
yield root, dirs, files
for _ in dirs:
yield from self._dvc_walk(walk)
def _subrepo_walk(self, dir_path, **kwargs):
"""Walk into a new repo.
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(
dir_path, topdown=True, ignore_subrepos=not self._traverse_subrepos
)
if dvc_tree:
dvc_walk = dvc_tree.walk(dir_path, topdown=True, **kwargs)
else:
dvc_walk = None
yield from self._walk(tree_walk, dvc_walk, **kwargs)
def _walk(self, repo_walk, dvc_walk=None, dvcfiles=False):
assert repo_walk
try:
_, dvc_dirs, dvc_fnames = (
next(dvc_walk) if dvc_walk else (None, [], [])
)
repo_root, repo_dirs, repo_fnames = next(repo_walk)
except StopIteration:
return
# separate subdirs into shared dirs, dvc-only dirs, repo-only dirs
dvc_set = set(dvc_dirs)
repo_set = set(repo_dirs)
dvc_only = list(dvc_set - repo_set)
repo_only = list(repo_set - dvc_set)
shared = list(dvc_set & repo_set)
dirs = shared + dvc_only + repo_only
# merge file lists
files = {
fname
for fname in dvc_fnames + repo_fnames
if dvcfiles or not is_valid_filename(fname)
}
yield repo_root, dirs, list(files)
# set dir order for next recursion level - shared dirs first so that
# next() for both generators recurses into the same shared directory
dvc_dirs[:] = [dirname for dirname in dirs if dirname in dvc_set]
repo_dirs[:] = [dirname for dirname in dirs if dirname in repo_set]
for dirname in dirs:
dir_path = os.path.join(repo_root, dirname)
if self._is_dvc_repo(dir_path):
yield from self._subrepo_walk(dir_path, dvcfiles=dvcfiles)
elif dirname in shared:
yield from self._walk(repo_walk, dvc_walk, dvcfiles=dvcfiles)
elif dirname in dvc_set:
yield from self._dvc_walk(dvc_walk)
elif dirname in repo_set:
yield from self._walk(repo_walk, None, dvcfiles=dvcfiles)
def walk(
self, top, topdown=True, onerror=None, dvcfiles=False, **kwargs
): # pylint: disable=arguments-differ
"""Walk and merge both DVC and repo trees.
Args:
top: path to walk from
topdown: if True, tree 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
for walked directories.
Any kwargs will be passed into methods used for fetching and/or
streaming DVC outs from remotes.
"""
assert topdown
if not self.exists(top):
if onerror is not None:
onerror(FileNotFoundError(top))
return
if not self.isdir(top):
if onerror is not None:
onerror(NotADirectoryError(top))
return
tree, dvc_tree = self._get_tree_pair(top)
dvc_exists = dvc_tree and dvc_tree.exists(top)
repo_exists = tree.exists(top)
if dvc_exists:
dvc_walk = dvc_tree.walk(top, topdown=topdown, **kwargs)
if repo_exists:
repo_walk = tree.walk(
top,
topdown=topdown,
ignore_subrepos=not self._traverse_subrepos,
)
yield from self._walk(repo_walk, dvc_walk, dvcfiles=dvcfiles)
else:
yield from dvc_walk
else:
repo_walk = tree.walk(
top,
topdown=topdown,
onerror=onerror,
ignore_subrepos=not self._traverse_subrepos,
)
yield from self._walk(repo_walk, None, dvcfiles=dvcfiles)
def walk_files(self, top, **kwargs): # pylint: disable=arguments-differ
for root, _, files in self.walk(top, **kwargs):
for fname in files:
yield PathInfo(root) / fname
def get_dir_hash(self, path_info, **kwargs):
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_dir_hash(path_info, **kwargs)
except OutputNotFoundError:
pass
return super().get_dir_hash(path_info, **kwargs)
def get_file_hash(self, path_info):
"""Return file checksum for specified path.
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 OutputNotFoundError:
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)
@property
def hash_jobs(self): # pylint: disable=invalid-overridden-method
return self._main_repo.tree.hash_jobs
def metadata(self, path):
path_info = PathInfo(os.path.abspath(path))
tree, dvc_tree = self._get_tree_pair(path_info)
dvc_meta = None
if dvc_tree:
with suppress(OutputNotFoundError):
dvc_meta = dvc_tree.metadata(path_info)
stat_result = None
with suppress(FileNotFoundError):
stat_result = tree.stat(path_info)
if not stat_result and not dvc_meta:
raise FileNotFoundError
meta = dvc_meta or Metadata(path_info=path_info)
isdir = bool(stat_result) and stat.S_ISDIR(stat_result.st_mode)
meta.isdir = meta.isdir or isdir
if not dvc_meta:
meta.is_exec = bool(stat_result) and is_exec(stat_result.st_mode)
return meta
+85 -77
View File
@@ -1,14 +1,17 @@
import logging
import os
import threading
from contextlib import contextmanager
from funcy import cached_property, wrap_prop
from dvc.config import ConfigError
from dvc.exceptions import DvcException, ETagMismatchError
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 .base import BaseTree
@@ -31,11 +34,6 @@ class S3Tree(BaseTree):
self.profile = config.get("profile")
self.endpoint_url = config.get("endpointurl")
if config.get("listobjects"):
self.list_objects_api = "list_objects"
else:
self.list_objects_api = "list_objects_v2"
self.use_ssl = config.get("use_ssl", True)
self.extra_args = {}
@@ -75,24 +73,49 @@ class S3Tree(BaseTree):
session = boto3.session.Session(**session_opts)
return session.client(
return session.resource(
"s3", endpoint_url=self.endpoint_url, use_ssl=self.use_ssl
)
@classmethod
def get_etag(cls, s3, bucket, path):
obj = cls.get_head_object(s3, bucket, path)
return obj["ETag"].strip('"')
@staticmethod
def get_head_object(s3, bucket, path, *args, **kwargs):
@contextmanager
def _get_s3(self):
from botocore.exceptions import (
EndpointConnectionError,
NoCredentialsError,
)
try:
obj = s3.head_object(Bucket=bucket, Key=path, *args, **kwargs)
except Exception as exc:
raise DvcException(f"s3://{bucket}/{path} does not exist") from exc
return obj
yield self.s3
except NoCredentialsError as exc:
link = error_link("no-credentials")
raise DvcException(
f"Unable to find AWS credentials. {link}"
) from exc
except EndpointConnectionError as exc:
link = error_link("connection-error")
name = self.endpoint_url or "AWS S3"
raise DvcException(
f"Unable to connect to '{name}'. {link}"
) from exc
@contextmanager
def _get_bucket(self, bucket):
with self._get_s3() as s3:
try:
yield s3.Bucket(bucket)
except s3.meta.client.exceptions.NoSuchBucket as exc:
link = error_link("no-bucket")
raise DvcException(
f"Bucket '{bucket}' does not exist. {link}"
) from exc
@contextmanager
def _get_obj(self, path_info):
with self._get_bucket(path_info.bucket) as bucket:
try:
yield bucket.Object(path_info.path)
except bucket.meta.client.exceptions.NoSuchKey as exc:
raise DvcException(f"{path_info.url} does not exist") from exc
def _append_aws_grants_to_extra_args(self, config):
# Keys for extra_args can be one of the following list:
@@ -127,9 +150,12 @@ class S3Tree(BaseTree):
def _generate_download_url(self, path_info, expires=3600):
params = {"Bucket": path_info.bucket, "Key": path_info.path}
return self.s3.generate_presigned_url(
ClientMethod="get_object", Params=params, ExpiresIn=int(expires)
)
with self._get_s3() as s3:
return s3.meta.client.generate_presigned_url(
ClientMethod="get_object",
Params=params,
ExpiresIn=int(expires),
)
def exists(self, path_info, use_dvcignore=True):
"""Check if the blob exists. If it does not exist,
@@ -164,36 +190,19 @@ class S3Tree(BaseTree):
return bool(list(self._list_paths(dir_path, max_items=1)))
def isfile(self, path_info):
from botocore.exceptions import ClientError
if path_info.path.endswith("/"):
return False
try:
self.s3.head_object(Bucket=path_info.bucket, Key=path_info.path)
except ClientError as exc:
if exc.response["Error"]["Code"] != "404":
raise
return False
return True
def _list_objects(self, path_info, max_items=None):
""" Read config for list object api, paginate through list objects."""
kwargs = {
"Bucket": path_info.bucket,
"Prefix": path_info.path,
"PaginationConfig": {"MaxItems": max_items},
}
paginator = self.s3.get_paginator(self.list_objects_api)
for page in paginator.paginate(**kwargs):
contents = page.get("Contents", ())
yield from contents
return path_info.path in self._list_paths(path_info)
def _list_paths(self, path_info, max_items=None):
return (
item["Key"] for item in self._list_objects(path_info, max_items)
)
kwargs = {"Prefix": path_info.path}
if max_items is not None:
kwargs["MaxKeys"] = max_items
with self._get_bucket(path_info.bucket) as bucket:
for obj_summary in bucket.objects.filter(**kwargs):
yield obj_summary.key
def walk_files(self, path_info, **kwargs):
if not kwargs.pop("prefix", False):
@@ -209,7 +218,8 @@ class S3Tree(BaseTree):
raise NotImplementedError
logger.debug(f"Removing {path_info}")
self.s3.delete_object(Bucket=path_info.bucket, Key=path_info.path)
with self._get_obj(path_info) as obj:
obj.delete()
def makedirs(self, path_info):
# We need to support creating empty directories, which means
@@ -221,10 +231,12 @@ class S3Tree(BaseTree):
return
dir_path = path_info / ""
self.s3.put_object(Bucket=path_info.bucket, Key=dir_path.path, Body="")
with self._get_obj(dir_path) as obj:
obj.put(Body="")
def copy(self, from_info, to_info):
self._copy(self.s3, from_info, to_info, self.extra_args)
with self._get_s3() as s3:
self._copy(s3.meta.client, from_info, to_info, self.extra_args)
@classmethod
def _copy_multipart(
@@ -238,8 +250,8 @@ class S3Tree(BaseTree):
parts = []
byte_position = 0
for i in range(1, n_parts + 1):
obj = S3Tree.get_head_object(
s3, from_info.bucket, from_info.path, PartNumber=i
obj = s3.head_object(
Bucket=from_info.bucket, Key=from_info.path, PartNumber=i
)
part_size = obj["ContentLength"]
lastbyte = byte_position + part_size - 1
@@ -293,7 +305,7 @@ class S3Tree(BaseTree):
# object is transfered in the same chunks as it was originally.
from boto3.s3.transfer import TransferConfig
obj = S3Tree.get_head_object(s3, from_info.bucket, from_info.path)
obj = s3.head_object(Bucket=from_info.bucket, Key=from_info.path)
etag = obj["ETag"].strip('"')
size = obj["ContentLength"]
@@ -313,36 +325,32 @@ class S3Tree(BaseTree):
Config=TransferConfig(multipart_threshold=size + 1),
)
cached_etag = S3Tree.get_etag(s3, to_info.bucket, to_info.path)
cached_etag = s3.head_object(Bucket=to_info.bucket, Key=to_info.path)[
"ETag"
].strip('"')
if etag != cached_etag:
raise ETagMismatchError(etag, cached_etag)
def get_file_hash(self, path_info):
return self.get_etag(self.s3, path_info.bucket, path_info.path)
with self._get_obj(path_info) as obj:
return HashInfo(self.PARAM_CHECKSUM, obj.e_tag.strip('"'))
def _upload(self, from_file, to_info, name=None, no_progress_bar=False):
total = os.path.getsize(from_file)
with Tqdm(
disable=no_progress_bar, total=total, bytes=True, desc=name
) as pbar:
self.s3.upload_file(
from_file,
to_info.bucket,
to_info.path,
Callback=pbar.update,
ExtraArgs=self.extra_args,
)
with self._get_obj(to_info) as obj:
total = os.path.getsize(from_file)
with Tqdm(
disable=no_progress_bar, total=total, bytes=True, desc=name
) as pbar:
obj.upload_file(
from_file, Callback=pbar.update, ExtraArgs=self.extra_args,
)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
if no_progress_bar:
total = None
else:
total = self.s3.head_object(
Bucket=from_info.bucket, Key=from_info.path
)["ContentLength"]
with Tqdm(
disable=no_progress_bar, total=total, bytes=True, desc=name
) as pbar:
self.s3.download_file(
from_info.bucket, from_info.path, to_file, Callback=pbar.update
)
with self._get_obj(from_info) as obj:
with Tqdm(
disable=no_progress_bar,
total=obj.content_length,
bytes=True,
desc=name,
) as pbar:
obj.download_file(to_file, Callback=pbar.update)
+2 -1
View File
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
from funcy import first, memoize, silent, wrap_with
import dvc.prompt as prompt
from dvc.hash_info import HashInfo
from dvc.scheme import Schemes
from ..base import BaseTree
@@ -238,7 +239,7 @@ class SSHTree(BaseTree):
raise NotImplementedError
with self.ssh(path_info) as ssh:
return ssh.md5(path_info.path)
return HashInfo(self.PARAM_CHECKSUM, ssh.md5(path_info.path))
def getsize(self, path_info):
with self.ssh(path_info) as ssh:
+246
View File
@@ -0,0 +1,246 @@
import logging
import os
import threading
from collections import deque
from funcy import cached_property, 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 .http import ask_password
logger = logging.getLogger(__name__)
class WebDAVConnectionError(DvcException):
def __init__(self, host):
super().__init__(f"Unable to connect to WebDAV {host}.")
class WebDAVTree(BaseTree): # pylint:disable=abstract-method
# Use webdav scheme
scheme = Schemes.WEBDAV
# URLInfo for Webdav ~ replaces webdav -> http
PATH_CLS = WebDAVURLInfo
# Traversable as walk_files is implemented
CAN_TRAVERSE = True
# Length of walk_files prefix
TRAVERSE_PREFIX_LEN = 2
# Implementation based on webdav3.client
REQUIRES = {"webdavclient3": "webdav3.client"}
# Chunk size for buffered upload/download with progress bar
CHUNK_SIZE = 2 ** 16
# Constructor
def __init__(self, repo, config):
# Call BaseTree constructor
super().__init__(repo, config)
# Get username from configuration
self.user = config.get("user", None)
# Get password from configuration (might be None ~ not set)
self.password = config.get("password", None)
# Whether to ask for password if it is not set
self.ask_password = config.get("ask_password", False)
# Use token for webdav auth
self.token = config.get("token", None)
# Path to certificate
self.cert_path = config.get("cert_path", None)
# Path to private key
self.key_path = config.get("key_path", None)
# Connection timeout
self.timeout = config.get("timeout", 30)
# Get URL from configuration
self.url = config.get("url", None)
# If URL in config parse path_info
if self.url:
self.path_info = self.PATH_CLS(self.url)
# If username not specified try to use from URL
if self.user is None and self.path_info.user is not None:
self.user = self.path_info.user
# If username specified add to path_info
if self.user is not None:
self.path_info.user = self.user
else:
self.path_info = None
# 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)
# Setup webdav client options dictionary
options = {
"webdav_hostname": hostname,
"webdav_login": self.user,
"webdav_password": self.password,
"webdav_token": self.token,
"webdav_cert_path": self.cert_path,
"webdav_key_path": self.key_path,
"webdav_timeout": self.timeout,
"webdav_chunk_size": self.CHUNK_SIZE,
}
client = Client(options)
# Check whether client options are valid
if not client.valid():
raise ConfigError(
f"Configuration for WebDAV {hostname} is invalid."
)
# Check whether connection is valid (root should always exist)
if not client.check(self.path_info.path):
raise WebDAVConnectionError(hostname)
return client
# 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
return self._client.is_dir(path_info.path)
# Yields path info to all files
def walk_files(self, path_info, **kwargs):
# Check whether directory exists
if not self.exists(path_info):
return
# Collect directories
dirs = deque([path_info.path])
# Iterate all directories found so far
while dirs:
# Iterate directory content
for entry in self._client.list(dirs.pop(), get_info=True):
# Construct path_info to entry
info = path_info.replace(path=entry["path"])
# Check whether entry is a directory
if entry["isdir"]:
# Append new found directory to directory list
dirs.append(info.path)
else:
# Yield path info to non directory
yield info
# Removes file/directory
def remove(self, path_info):
# Use webdav client clean (DELETE) method to remove file/directory
self._client.clean(path_info.path)
# Creates directories
def makedirs(self, path_info):
# Terminate recursion
if path_info.path == self.path_info.path or self.exists(path_info):
return
# Recursively descent to root
self.makedirs(path_info.parent)
# Construct directory at current recursion depth
self._client.mkdir(path_info.path)
# Moves file/directory at remote
def move(self, from_info, to_info, mode=None):
# Webdav client move
self._client.move(from_info.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
with open(to_file, "wb") as fd:
with Tqdm.wrapattr(
fd,
"write",
total=None if no_progress_bar else self._file_size(from_info),
leave=False,
desc=from_info.url if name is None else name,
disable=no_progress_bar,
) as fd_wrapped:
# Download from WebDAV via buffer
self._client.download_from(
buff=fd_wrapped, remote_path=from_info.path
)
# Uploads file to remote
def _upload(self, from_file, to_info, name=None, no_progress_bar=False):
# First try to create parent directories
self.makedirs(to_info.parent)
# Progress from HTTPTree
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
# Upload to WebDAV via buffer
self._client.upload_to(buff=chunks(), 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"])
+7
View File
@@ -0,0 +1,7 @@
from dvc.scheme import Schemes
from .webdav import WebDAVTree
class WebDAVSTree(WebDAVTree): # pylint:disable=abstract-method
scheme = Schemes.WEBDAVS
+18 -2
View File
@@ -7,6 +7,7 @@ import colorama
from packaging import version
from dvc import __version__
from dvc.config import Config, to_bool
from dvc.lock import LockError, make_lock
from dvc.utils import boxify, env2bool
from dvc.utils.pkg import PKG
@@ -34,7 +35,7 @@ class Updater: # pragma: no cover
ctime = os.path.getmtime(self.updater_file)
outdated = time.time() - ctime >= self.TIMEOUT
if outdated:
logger.debug(f"'{self.updater_file}' is outdated(")
logger.debug(f"'{self.updater_file}' is outdated")
return outdated
def _with_lock(self, func, action):
@@ -46,7 +47,12 @@ class Updater: # pragma: no cover
logger.debug(msg.format(self.lock.lockfile, action))
def check(self):
if os.getenv("CI") or env2bool("DVC_TEST") or PKG == "snap":
if (
os.getenv("CI")
or env2bool("DVC_TEST")
or PKG == "snap"
or not self.is_enabled()
):
return
self._with_lock(self._check, "checking")
@@ -82,6 +88,7 @@ class Updater: # pragma: no cover
def _get_latest_version(self):
import json
import requests
try:
@@ -144,3 +151,12 @@ class Updater: # pragma: no cover
package_manager = "binary"
return instructions[package_manager]
def is_enabled(self):
enabled = to_bool(
Config(validate=False).get("core", {}).get("check_update", "true")
)
logger.debug(
"Check for update is {}abled.".format("en" if enabled else "dis")
)
return enabled
+19 -20
View File
@@ -1,18 +1,17 @@
"""Helpers for other modules."""
import hashlib
import io
import json
import logging
import math
import os
import re
import stat
import sys
import time
import colorama
import nanotime
from ruamel.yaml import YAML
from shortuuid import uuid
logger = logging.getLogger(__name__)
@@ -45,8 +44,8 @@ def _fobj_md5(fobj, hash_md5, binary, progress_func=None):
def file_md5(fname, tree=None):
""" get the (md5 hexdigest, md5 digest) of a file """
from dvc.progress import Tqdm
from dvc.istextfile import istextfile
from dvc.progress import Tqdm
if tree:
exists_func = tree.exists
@@ -237,18 +236,6 @@ def current_timestamp():
return int(nanotime.timestamp(time.time()))
def from_yaml_string(s):
return YAML().load(io.StringIO(s))
def to_yaml_string(data):
stream = io.StringIO()
yaml = YAML()
yaml.default_flow_style = False
yaml.dump(data, stream)
return stream.getvalue()
def colorize(message, color=None):
"""Returns a message in a specified color."""
if not color:
@@ -363,19 +350,23 @@ def resolve_output(inp, out):
def resolve_paths(repo, out):
from urllib.parse import urlparse
from ..dvcfile import DVC_FILE_SUFFIX
from ..path_info import PathInfo
from .fs import contains_symlink_up_to
abspath = os.path.abspath(out)
abspath = PathInfo(os.path.abspath(out))
dirname = os.path.dirname(abspath)
base = os.path.basename(os.path.normpath(out))
# NOTE: `out` might not exist yet, so using `dirname`(aka `wdir`) to check
# if it is a local path.
scheme = urlparse(out).scheme
if os.name == "nt" and scheme == abspath.drive[0].lower():
# urlparse interprets windows drive letters as URL scheme
scheme = ""
if (
os.path.exists(dirname) # out might not exist yet, so
and PathInfo(abspath).isin_or_eq(repo.root_dir)
not scheme
and abspath.isin_or_eq(repo.root_dir)
and not contains_symlink_up_to(abspath, repo.root_dir)
):
wdir = dirname
@@ -394,6 +385,10 @@ def format_link(link):
)
def error_link(name):
return format_link(f"https://error.dvc.org/{name}")
def parse_target(target, default=None):
from dvc.dvcfile import PIPELINE_FILE, PIPELINE_LOCK, is_valid_filename
from dvc.exceptions import DvcException
@@ -425,3 +420,7 @@ def parse_target(target, default=None):
logger.debug("Assuming file to be '%s'", path)
return path, name
def is_exec(mode):
return mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+11
View File
@@ -1,5 +1,16 @@
import os
from collections.abc import Mapping
from pygtrie import StringTrie as _StringTrie
class PathStringTrie(_StringTrie):
"""Trie based on platform-dependent separator for pathname components."""
def __init__(self, *args, **kwargs):
kwargs["separator"] = os.sep
super().__init__(*args, **kwargs)
def apply_diff(src, dest):
"""Recursively apply changes from src to dest.
+3 -3
View File
@@ -1,6 +1,8 @@
import json
from collections import defaultdict
from .flatten import flatten
def _parse(raw):
if raw is None or isinstance(raw, (dict, list, int, float)):
@@ -35,9 +37,7 @@ def _flatten(d):
return defaultdict(lambda: None)
if isinstance(d, dict):
from flatten_json import flatten as fltn
return defaultdict(lambda: None, fltn(d, "."))
return defaultdict(lambda: None, flatten(d))
return defaultdict(lambda: "unable to parse")
+9
View File
@@ -0,0 +1,9 @@
import flatten_dict
def flatten(d):
return flatten_dict.flatten(d, reducer="dot")
def unflatten(d):
return flatten_dict.unflatten(d, splitter="dot")
+2 -2
View File
@@ -12,8 +12,8 @@ logger = logging.getLogger(__name__)
DEFAULT_PAGER = "less"
DEFAULT_PAGER_FORMATTED = "{} --chop-long-lines --clear-screen".format(
DEFAULT_PAGER
DEFAULT_PAGER_FORMATTED = (
f"{DEFAULT_PAGER} --chop-long-lines --clear-screen --RAW-CONTROL-CHARS"
)
+12
View File
@@ -0,0 +1,12 @@
from collections import defaultdict
from ._common import * # noqa, pylint: disable=wildcard-import
from ._json import * # noqa, pylint: disable=wildcard-import
from ._toml import * # noqa, pylint: disable=wildcard-import
from ._yaml import * # noqa, pylint: disable=wildcard-import
LOADERS = defaultdict(lambda: load_yaml) # noqa: F405
LOADERS.update({".toml": load_toml, ".json": load_json}) # noqa: F405
MODIFIERS = defaultdict(lambda: modify_yaml) # noqa: F405
MODIFIERS.update({".toml": modify_toml, ".json": modify_json}) # noqa: F405
+34
View File
@@ -0,0 +1,34 @@
"""Common utilities for serialize."""
import os
from contextlib import contextmanager
from dvc.exceptions import DvcException
from dvc.utils import relpath
class ParseError(DvcException):
"""Errors while parsing files"""
def __init__(self, path, message):
path = relpath(path)
super().__init__(f"unable to read: '{path}', {message}")
def _load_data(path, parser, tree=None):
open_fn = tree.open if tree else open
with open_fn(path, encoding="utf-8") as fd:
return parser(fd.read(), path)
def _dump_data(path, data, dumper, tree=None):
open_fn = tree.open if tree else open
with open_fn(path, "w+", encoding="utf-8") as fd:
dumper(data, fd)
@contextmanager
def _modify_data(path, parser, dumper, tree=None):
exists = tree.exists if tree else os.path.exists
data = _load_data(path, parser=parser, tree=tree) if exists(path) else {}
yield data
dumper(path, data, tree=tree)
+29
View File
@@ -0,0 +1,29 @@
import json
from funcy import contextmanager, reraise
from ._common import ParseError, _dump_data, _load_data, _modify_data
class JSONFileCorruptedError(ParseError):
def __init__(self, path):
super().__init__(path, "JSON file structure is corrupted")
def load_json(path, tree=None):
return _load_data(path, parser=parse_json, tree=tree)
def parse_json(text, path):
with reraise(json.JSONDecodeError, JSONFileCorruptedError(path)):
return json.loads(text) or {}
def dump_json(path, data, tree=None):
return _dump_data(path, data, dumper=json.dump, tree=tree)
@contextmanager
def modify_json(path, tree=None):
with _modify_data(path, parse_json, dump_json, tree=tree) as d:
yield d
+45
View File
@@ -0,0 +1,45 @@
from contextlib import contextmanager
import toml
from funcy import reraise
from ._common import ParseError, _dump_data, _load_data, _modify_data
class TOMLFileCorruptedError(ParseError):
def __init__(self, path):
super().__init__(path, "TOML file structure is corrupted")
def load_toml(path, tree=None):
return _load_data(path, parser=parse_toml, tree=tree)
def parse_toml(text, path, decoder=None):
with reraise(toml.TomlDecodeError, TOMLFileCorruptedError(path)):
return toml.loads(text, decoder=decoder)
def parse_toml_for_update(text, path):
"""Parses text into Python structure.
NOTE: Python toml package does not currently use ordered dicts, so
keys may be re-ordered between load/dump, but this function will at
least preserve comments.
"""
decoder = toml.TomlPreserveCommentDecoder()
return parse_toml(text, path, decoder=decoder)
def _dump(data, stream):
return toml.dump(data, stream, encoder=toml.TomlPreserveCommentEncoder())
def dump_toml(path, data, tree=None):
return _dump_data(path, data, dumper=_dump, tree=tree)
@contextmanager
def modify_toml(path, tree=None):
with _modify_data(path, parse_toml_for_update, dump_toml, tree=tree) as d:
yield d
+71
View File
@@ -0,0 +1,71 @@
import io
from collections import OrderedDict
from contextlib import contextmanager
from funcy import reraise
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
from ._common import ParseError, _dump_data, _load_data, _modify_data
class YAMLFileCorruptedError(ParseError):
def __init__(self, path):
super().__init__(path, "YAML file structure is corrupted")
def load_yaml(path, tree=None):
return _load_data(path, parser=parse_yaml, tree=tree)
def parse_yaml(text, path, typ="safe"):
yaml = YAML(typ=typ)
with reraise(YAMLError, YAMLFileCorruptedError(path)):
return yaml.load(text) or {}
def parse_yaml_for_update(text, path):
"""Parses text into Python structure.
Unlike `parse_yaml()` this returns ordered dicts, values have special
attributes to store comments and line breaks. This allows us to preserve
all of those upon dump.
This one is, however, several times slower than simple `parse_yaml()`.
"""
return parse_yaml(text, path, typ="rt")
def _get_yaml():
yaml = YAML()
yaml.default_flow_style = False
# tell Dumper to represent OrderedDict as normal dict
yaml_repr_cls = yaml.Representer
yaml_repr_cls.add_representer(OrderedDict, yaml_repr_cls.represent_dict)
return yaml
def _dump(data, stream):
yaml = _get_yaml()
return yaml.dump(data, stream)
def dump_yaml(path, data, tree=None):
return _dump_data(path, data, dumper=_dump, tree=tree)
def loads_yaml(s, typ="safe"):
return YAML(typ=typ).load(s)
def dumps_yaml(d):
stream = io.StringIO()
_dump(d, stream)
return stream.getvalue()
@contextmanager
def modify_yaml(path, tree=None):
with _modify_data(path, parse_yaml_for_update, dump_yaml, tree=tree) as d:
yield d
-53
View File
@@ -1,53 +0,0 @@
from collections import OrderedDict
from ruamel.yaml import YAML
from ruamel.yaml.error import YAMLError
from dvc.exceptions import YAMLFileCorruptedError
try:
from yaml import CSafeLoader as SafeLoader
except ImportError:
from yaml import SafeLoader
def load_yaml(path):
with open(path, encoding="utf-8") as fd:
return parse_yaml(fd.read(), path)
def parse_yaml(text, path):
try:
import yaml
return yaml.load(text, Loader=SafeLoader) or {}
except yaml.error.YAMLError as exc:
raise YAMLFileCorruptedError(path) from exc
def parse_yaml_for_update(text, path):
"""Parses text into Python structure.
Unlike `parse_yaml()` this returns ordered dicts, values have special
attributes to store comments and line breaks. This allows us to preserve
all of those upon dump.
This one is, however, several times slower than simple `parse_yaml()`.
"""
try:
yaml = YAML()
return yaml.load(text) or {}
except YAMLError as exc:
raise YAMLFileCorruptedError(path) from exc
def dump_yaml(path, data):
with open(path, "w", encoding="utf-8") as fd:
yaml = YAML()
yaml.default_flow_style = False
# tell Dumper to represent OrderedDict as
# normal dict
yaml.Representer.add_representer(
OrderedDict, yaml.Representer.represent_dict
)
yaml.dump(data, fd)
+1 -1
View File
@@ -6,7 +6,7 @@
import os
import subprocess
_BASE_VERSION = "1.2.3"
_BASE_VERSION = "1.6.6"
def _generate_version(base_version):
+5
View File
@@ -0,0 +1,5 @@
from PyInstaller.utils.hooks import ( # pylint:disable=import-error
copy_metadata,
)
datas = copy_metadata("flatten-dict")
+1 -5
View File
@@ -15,10 +15,6 @@ show_source=true
count=true
[isort]
include_trailing_comma=true
profile=black
known_first_party=dvc,tests
known_third_party=PyInstaller,RangeHTTPServer,boto3,colorama,configobj,distro,dpath,flaky,flufl,funcy,git,grandalf,mock,moto,nanotime,networkx,packaging,pathspec,pygtrie,pylint,pytest,requests,ruamel,setuptools,shortuuid,shtab,toml,tqdm,voluptuous,yaml,zc
line_length=79
force_grid_wrap=0
use_parentheses=True
multi_line_output=3
+12 -9
View File
@@ -60,7 +60,6 @@ install_requires = [
"grandalf==0.6",
"distro>=1.3.0",
"appdirs>=1.4.3",
"PyYAML>=5.1.2,<5.4", # Compatibility with awscli
"ruamel.yaml>=0.16.1",
"toml>=0.10.1",
"funcy>=1.14",
@@ -75,12 +74,14 @@ install_requires = [
"networkx>=2.1,<2.5",
"pydot>=1.2.4",
"speedcopy>=2.0.1; python_version < '3.8' and sys_platform == 'win32'",
"flatten_json>=0.1.6,<0.1.8",
"dataclasses; python_version < '3.7'",
"flatten_dict>=0.3.0,<1",
"tabulate>=0.8.7",
"pygtrie==2.3.2",
"dpath>=2.0.1,<3",
"shtab>=1.1.0,<2",
"shtab>=1.3.0,<2",
"rich>=3.0.5",
"dictdiffer>=0.8.1",
]
@@ -89,22 +90,23 @@ install_requires = [
gs = ["google-cloud-storage==1.19.0"]
gdrive = ["pydrive2>=1.4.14"]
s3 = ["boto3>=1.9.201"]
azure = ["azure-storage-blob==2.1.0", "knack"]
azure = ["azure-storage-blob>=12.0", "knack"]
oss = ["oss2==2.6.1"]
ssh = ["paramiko>=2.5.0"]
hdfs = ["pyarrow>=0.17.0"]
webdav = ["webdavclient3>=3.14.5"]
# gssapi should not be included in all_remotes, because it doesn't have wheels
# for linux and mac, so it will fail to compile if user doesn't have all the
# requirements, including kerberos itself. Once all the wheels are available,
# we can start shipping it by default.
ssh_gssapi = ["paramiko[gssapi]>=2.5.0"]
all_remotes = gs + s3 + azure + ssh + oss + gdrive + hdfs
all_remotes = gs + s3 + azure + ssh + oss + gdrive + hdfs + webdav
# Extra dependecies to run tests
tests_requirements = [
"wheel>=0.31.1",
# Test requirements:
"pytest>=4.6.0",
"pytest>=6.0.1",
"pytest-docker>=0.7.2",
"pytest-timeout>=1.3.3",
"pytest-cov>=2.6.1",
@@ -118,7 +120,7 @@ tests_requirements = [
"google-compute-engine==2.8.13",
"Pygments", # required by collective.checkdocs,
"collective.checkdocs",
"flake8==3.7.9",
"flake8==3.8.3",
"psutil",
"flake8-docstrings",
"pydocstyle<4.0",
@@ -130,8 +132,8 @@ tests_requirements = [
"flake8-bugbear",
"flake8-comprehensions",
"flake8-string-format",
"pylint",
"pylint-pytest",
"pylint==2.5.3",
"pylint-pytest>=0.3.0",
"pylint-plugin-utils",
"wget",
"filelock",
@@ -160,6 +162,7 @@ setup(
"ssh": ssh,
"ssh_gssapi": ssh_gssapi,
"hdfs": hdfs,
"webdav": webdav,
"tests": tests_requirements,
},
keywords="data-science data-version-control machine-learning git"
+2 -1
View File
@@ -2,9 +2,10 @@ import os
# Increasing fd ulimit for tests
if os.name == "nt":
import win32file # pylint: disable=import-error
import subprocess
import win32file # pylint: disable=import-error
win32file._setmaxstdio(2048)
# Workaround for two bugs:

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