Compare commits

..
380 Commits
Author SHA1 Message Date
Ruslan Kuprieiev c989b28eae dvc: bump to 1.10.2 2020-11-24 18:33:49 +02:00
Saugat Pachhai abb78ce19d parametrization: print pretty interpolation errors (#4953)
* parametrization: print pretty interpolation errors

* Fix col/loc in exception msg

* Fix tests
2020-11-24 18:26:26 +05:45
Saugat Pachhai bc4c9fe9ea parametrization: prevent dumping parametrized stages (#4950)
* parametrization: prevent dumping parametrized stages

This is a bit hackish, but does the job done.
It feels like we need some unified way to be able to load
and dump, but not sure how. Till then, this dataclass
will be helpful in communicating between those.

* s/Meta/RawData

* Update dvc/stage/loader.py
2020-11-24 17:40:47 +05:45
Saugat Pachhai d138bdd72a Add mypy config, fix type errors in a few remaining places (#4951)
* Add mypy config, fix type errors in a few random places

* Add typing_extensions

* Use setup.cfg for mypy's config
2020-11-24 14:19:55 +05:45
Saugat Pachhai 7c3c2e8123 Fix typechecking errors - 2 (#4946) 2020-11-24 11:18:11 +05:45
Saugat Pachhai 04c53601c0 types: add types to dvc/tree (#4947) 2020-11-24 11:16:28 +05:45
Saugat Pachhai 3ba70d375e types: fix type errors on dvc.collect (#4948) 2020-11-24 11:16:02 +05:45
Saugat Pachhai 85ad7358d9 types: fix type checking errors in dvc.experiments (#4949) 2020-11-24 07:22:42 +05:45
Saugat Pachhai cb56f57908 Fix typechecking errors around a few places (#4945) 2020-11-23 19:00:45 +05:45
Saugat Pachhai a7a007e4f9 Fix typechecking errors in dvc/repo (#4944) 2020-11-23 17:27:04 +05:45
Saugat Pachhai e33274723d Move flags from tests to config (#4943) 2020-11-23 16:21:24 +05:45
Saugat Pachhai f1efc8f409 tests: increase ulimit to 4096 (#4942) 2020-11-23 11:22:15 +02:00
Saugat Pachhai 1a2c91eb5a Use PathStringTrie consistently (#4941)
This one was missed for some reason.
2020-11-23 12:44:43 +05:45
Saugat Pachhai 806f8032d9 ci: reduce no. of MacOS jobs (#4940)
This was increasing the wait times of the CI.

Also enabled codecov failure to not fail the whole CI pipeline.
2020-11-23 12:12:58 +05:45
Saugat Pachhai 8cb4202292 Fix metadata tests running with module-scoped fixtures (#4939)
I ran it this way before, to make it run fast. But, it's not ideal
for running with xdist.
2020-11-23 11:45:45 +05:45
Saugat Pachhai 5faae2a6e9 test: allow selectively running tests for remotes (#4920)
Some of the tests are run by default, other requires
`--enable-{remote}` to run. The remotes that are run by default
also have a `--disable-{remote}` flag. All of the remotes can be
enabled using `--all` flag.

To use this functionality in the fixture,
`test_config` fixture has to be used, and requires to call
`test_config.requires(remote)`.
In an individual test, you can simply use `pytest.mark.remote`
to make them skip or run.
2020-11-23 11:43:39 +05:45
Saugat Pachhai 6237a665c3 tests: indices syntax for parametrization (#4930) 2020-11-20 20:19:47 +05:45
Saugat Pachhai 011bd35a6c parametrization: change foreach stage name joiner from "-" to "@" (#4929) 2020-11-20 19:49:46 +05:45
Saugat Pachhai 133018af78 s/foreach..in/foreach..do (#4928) 2020-11-20 19:47:58 +05:45
Saugat Pachhai c65fdcabae tests: add error message tests for resolving stages (#4927) 2020-11-20 19:42:59 +05:45
Saugat Pachhai e1692a6be0 parametrization: improve error messages on foreach loop (#4926) 2020-11-20 18:54:12 +05:45
Saugat Pachhai c8f37fdb88 parsing: improve error messages when resolving keys of the stages (#4925) 2020-11-20 18:16:23 +05:45
Saugat Pachhai 7f66db7039 parametrization: improve 'vars' error message (#4924)
* Catches MergeError
* Catches params file not existing error
2020-11-20 17:46:34 +05:45
Saugat Pachhai 470f411cd6 parametrization: improve set error message (#4923) 2020-11-20 17:08:05 +05:45
Saugat Pachhai d721e919e4 parametrization: improve key error message (#4922) 2020-11-20 16:38:32 +05:45
Saugat Pachhai c03fbb0056 parametrization: raise appropriate error message on merge error (#4921) 2020-11-20 16:04:33 +05:45
Saugat Pachhai cb3e52829b interpolation: support [] indices syntax (#4918)
Via pyparsing. The error message is not that great, as it's greedy.
But, it does point out where the mistakes are, correctly most of the
time. :)

Also raises appropriate error messages for ParseError
2020-11-20 14:31:16 +05:45
Saugat Pachhai 4b7d130003 Skip already used vars, even if they are in a relative path (#4917)
Previously, "dir/../params.yaml" would have thrown an OverwriteError
2020-11-20 14:10:26 +05:45
Saugat Pachhai d5312e56a1 parametrization: Move resolver to StageLoader from Dvcfile (#4916) 2020-11-20 13:38:57 +05:45
Ruslan Kuprieiev 2a6c4291ce dag: support output as target (#4908)
Fixes #4904
2020-11-20 06:57:12 +02:00
Saugat Pachhai 40c3bbd1ba Change use and vars to just vars (#4880)
The schema is changed of `vars` to accomodate the both use of
`imports` and setting variables locally.

Now:
```
vars:
  - params.yaml
  - foo: foo
    bar: bar
```

Also, same distinction occurs for the stages.
2020-11-20 09:19:13 +05:45
Sami Jawhar 92b17e3a89 Fix exp checkout queued and cleanup (#4914) 2020-11-20 09:16:12 +09:00
Sami Jawhar 153c374c02 experiments: pop checkpoint resume from kwargs (#4913) 2020-11-20 09:12:13 +09:00
Ruslan Kuprieiev 4cf2f8139b dvc: bump to 1.10.1 2020-11-19 21:21:41 +02:00
Peter Rowlands (변기호) c2ec8b27ca exp: fix bug where repro with run-cached stages may fail (#4911) 2020-11-20 00:57:34 +09:00
Arun MahapatraandRuslan Kuprieiev 6324bd8a69 dvc: include cache urls in info output. (#4848)
* dvc: include cache urls in info output.

Show cache urls for external and remote cache in the info output
to assist in diagnosis of issues.

Fixes #4574.

* Remove unnecessary constant check for generator expression.

* Add remotes and caches in version command for better diagnosis.

* Fix flaky unit test to be runtime independent by using regex search.

* Use Cache.by_scheme() for enumerating supported caches.

* Add get_cloud_schemes routine for retrieval of configured remote schemes.

Add tests for local remote and remote:// scheme for info.

* Cleanup unused code.

* info: refactor remote parsing

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-11-19 05:59:33 +02:00
Ruslan Kuprieiev 251a3269c2 dvcfile: add desc field for outs/stages (#4901)
Outs desc will be used by viewer right away. Stage desc is for people to
start using now, but we won't be using it it viewer until DAG support is
added there.
2020-11-18 22:06:52 +02:00
Ruslan Kuprieiev b340254556 deps: freeze dataclasses version for rich
https://github.com/iterative/dvc/runs/1417985315?check_suite_focus=true
2020-11-18 17:02:22 +02:00
Paweł Redzyński 7501302baa metrics/plots: common tests: check for particular exceptions (#4865) 2020-11-18 14:14:19 +02:00
Ruslan Kuprieiev d7564e9965 dvc: bump to 1.10.0 2020-11-18 03:17:12 +02:00
Ruslan Kuprieiev d06dd3caa9 readme: replace travis with gha 2020-11-18 02:55:58 +02:00
Ruslan Kuprieiev 9741f6d706 gha: rename "Tests build" -> "Tests" 2020-11-18 02:55:24 +02:00
Tomás Farías SantanaandRuslan Kuprieiev ca2e6a004c Support for WebHDFS (#4747)
* Add support for WebHDFS

* Style and linting fixes

* Implement abstract methods and disable no-member pylint errors

* Add init unit test

* Yield client and fix redefinition of hdfs

* Get dynamic hdfs Docker port

* Add support for open in WebHDFSTree

* Remove hdfscli_config fallbacks in WebHDFSTree

* Use port 50070 for webhdfs

* WebHDFSTree supports http URL and proper walking

* Fix failing unit test after refactoring

* Add overwrite argument to upload

* Add remaining func tests for webhdfs

* Remove print statements

* Allow dynamic attributes in pylint for hdfs library

* Comment error on instantiating HDFS client from config

* Remove depth argument from call to walk

* Update dvc/tree/webhdfs.py

* Remove unnecessary deque

* Check exception messages hdfs client failure from config

* Return client directly

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

* Split long line

* Add size to HashInfo

* Implement move via hdfs.rename

* Add missing line

* Make parent directories before moving file in WebHDFS

* Add total to progress bar

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-11-17 20:02:02 +02:00
Peter Rowlands (변기호) 40bdf613f0 experiments: preserve file permissions across tmpdir execution (#4890)
* tree: use file_mode in upload() for trees that support modes

* experiments: preserve file_mode for files in the workspace

* skip permissions tests on windows

* LocalTree: separate chmod from protect

* fix rebase issue
2020-11-18 00:47:57 +09:00
Peter Rowlands (변기호) 379ed8e6fc experiments: support branching w/modified params from intermediate checkpoint (#4898)
* experiments: Allow branching from checkpoints with modified hyper params

* experiments: initial checkpoints tree functionality for `exp show`

* go back to flat/sortable view

* add test for resume w/modified params

* tests: move experiments fixtures into conftest

* add show checkpoints test

* test checkpoint table rendering

* remove dead code

* test show for checkpoint branches
2020-11-17 21:09:46 +09:00
Ruslan Kuprieiev 2f6a8a7349 gha: upgrade checkout and setup-python 2020-11-17 05:54:33 +02:00
Ruslan Kuprieiev d5b7f7cf2c gha: upgrade setup-python to 2.1.4 2020-11-17 05:50:34 +02:00
Ruslan Kuprieiev 1788fe95dd gha: upgrade setup-python action to v2 2020-11-17 05:46:56 +02:00
Peter Rowlands (변기호) 96ead9425a ci: add macos-11 @ py3.8, py3.9 to build matrix (#4892) 2020-11-16 15:11:24 +09:00
Ruslan Kuprieiev f4349aa09b dvc: add --pdb flag (#4895)
This is a hidden flag (no docs needed) for a handy shortcut to pdb when
we need it. E.g. binaries built by pyinstaller are much easier to debug
with it.
2020-11-15 02:30:13 +02:00
Ilya PekelnyandRuslan Kuprieiev 2f620e9226 4504: configuration-based job limit. (#4872)
* Job count expected in a core config

Besides defaulted 4 * core_count number of workers sourced in the
"tree", the configuration file based value expected and prioritised.

* Multiply the configured value by cpu cores count.

* Don't multiply by the core count twice.

* A couple more usages and a question note.

* Don't multiply a number of jobs to a cpu count

Number of jobs set in a config file directly used to spawn workers.

* Remove the obsolete note.

* Disallow 0 jobs set in a config

* `jobs` config-based value is optional

* Apply suggestions from code review

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

* CPU count based default

* Update dvc/tree/base.py

* Happy path unit test

* Update tests/unit/remote/test_remote.py

* Fix the accidentally removed blank line

* Update dvc/tree/base.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-11-13 21:30:02 +02:00
Paweł Redzyński d3931def53 tests: real_s3 fixture: use specific path (#4884) 2020-11-12 16:36:08 +01:00
Saugat Pachhai 3ec64301b9 Send TAP result to flaky service only on master (#4888) 2020-11-12 20:05:52 +05:45
danielfleischer 8e738262ad utils/fs: checking files ownership in 'move' (#4348) (#4832)
utils/fs: checking files ownership in 'move' (#4348)
2020-11-12 14:00:48 +01:00
Peter Rowlands (변기호) 3daeeaffdb experiments: fix checkpoint/persist related checkout issues (#4885)
* dvcfile: add is_lock_file to identify pipeline lock files

* experiments: clean dirty lock files before stashing experiments
2020-11-12 19:28:28 +09:00
Guro Bokum eeba567774 dag: add --outs option (#4739)
Fixes #4532
2020-11-12 18:23:31 +09:00
Saugat Pachhai 72eb4a5737 Add test server and tests for webdav (#4827)
* Add test server and tests for webdav

* Fix webdav unittests
2020-11-12 13:52:46 +05:45
Sami Jawhar 72ec2a8541 Simpler param updates with python-benedict (#4780) 2020-11-12 16:42:28 +09:00
Peter Rowlands (변기호) 07676b2531 checkpoints: set DVC_ROOT environment variable (#4877)
* checkpoints: set DVC_ROOT environment variable

* env: add DVC_ROOT, DVC_CHECKPOINT
2020-11-12 16:26:07 +09:00
Ioana GrigoropolandRuslan Kuprieiev 556143fd7e api: add support for simple wildcards (#4864)
* api: add support for simple wildcards

Related to #4816.

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

* api: make wildcard interpretation optional

Adds a new argument for the add command `glob` that is disabled by default and when enabled it passes
the input targets through glob filtering.

Related: #4816

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

* Update dvc/repo/add.py

* Update dvc/repo/add.py

* Update dvc/repo/add.py

* Update dvc/repo/add.py

* Update dvc/repo/add.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-11-11 20:38:01 +02:00
Ioana Grigoropol b8cd02457a tests: mark azure test as flaky (#4881)
Related: #4792

Signed-off-by: Ioana Grigoropol <[email protected]>
2020-11-11 20:24:33 +02:00
Ruslan Kuprieiev de01b11483 setup.py: limit responses version for moto (#4879)
```
c:\hostedtoolcache\windows\python\3.6.8\x64\lib\site-packages\moto\core\models.py:17: in <module>
    import responses
c:\hostedtoolcache\windows\python\3.6.8\x64\lib\site-packages\responses.py:29: in <module>
    from urllib3.connection import HTTPHeaderDict
E   ImportError: cannot import name 'HTTPHeaderDict'
```
2020-11-11 17:30:27 +02:00
LucaButeraandLuca Butera 38d811035c remote: avoid chunking on webdav. Fixes #4796 (#4828)
* remote: avoid chunking on webdav. Fixes #4796

* remote: avoid chunking on webdav. Fixes #4796

* remote: avoid chunking on webdav. Fixes #4796

* remote: avoid chunking on webdav. Fixes #4796

* remote: avoid chunking on webdav. Fixes #4796

Co-authored-by: Luca Butera <[email protected]>
2020-11-11 17:59:26 +05:45
Peter Rowlands (변기호) 389efd7ae6 checkpoints: exp run and exp res[ume] refactor (#4855)
* experiments: refactor regular/checkpoint experiment separation

* fix scm_checkout pull bug

* experiments: return stages from executor.reproduce

* since executor has to move to threadpool execution anyways, we no
  longer have to work around the pickle repro result limitation

* refactor new/resume behavior

* refactor checkpoints tests

* use -f to force checkpoints reset/restart

* checkpoints: use 'exp run' and 'exp res[ume]' commands

* stage: all outs can be missing for checkpoint stages

* regular outputs may not be written at the time a checkpoint is
  generated, but subsequent checkouts of that stage should not fail due
  to the missing outs

* commit stage on checkpoint generation otherwise non-persist cached outs
are lost

* checkpoints: wait for stage proc to end after killing via terminate()

* stage: fix issues with killing checkpoint stage

* refactor signaling (use threading.Event instead of Condition)
* fix windows bug (kill all child processes via taskkill)

* experiments: refactor executor collection

* tests: move checkpoint tests into separate module

* refactor experiment tests to use stage fixture
2020-11-11 14:19:22 +09:00
Ruslan Kuprieiev f0ad1b4f93 dvc: refactor cache/remote relations (#4867) 2020-11-10 18:38:28 +02:00
Saugat Pachhai f6d40948d6 codecov: add target coverage, and threshold for patch and project (#4873)
* Reduce codecov target to be 85%

* Increase threshold for patch and project
2020-11-10 21:47:59 +05:45
Michal NowikowskiandRuslan Kuprieiev 4b27fd7c23 [#4136] fixed recalculating relative paths in config (#4802)
* [#4136] fixed recalculating relative paths in config

* Update dvc/config.py

* Update dvc/config.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-11-10 14:56:44 +02:00
Saugat Pachhai 4220baa602 parametrization: Support keys of the dictionary to be interpolated (#4869) 2020-11-10 17:32:58 +05:45
Ruslan Kuprieiev 2e9fa0607e cache: refactor cache types (#4866) 2020-11-09 21:28:46 +02:00
Ruslan Kuprieiev 685e76cfbe gitignore: add config.ini
Generated by innosetup.
2020-11-09 13:25:45 +02:00
Saugat Pachhai dca48fe8d7 Skip loading globally used params section again locally for stages (#4859)
We were still trying to load params file used in `use`
for local stages which was throwing overwrite error.
Thanks to @dmpetrov for pointing out.
2020-11-07 09:30:12 +05:45
Saugat Pachhai 9f1330a387 Fix merge_update not converting data to Node object (#4846)
We wrap every data to one of the `Node` object. But, since we were
passing `self.data` instead of `self`, it was skipping the conversion
and instead was just trying to set data as-is, which would have make
our latter assumptions incorrect, as other codebases assume it to be
a `Node`.

I have added asserts in a few places. Also, I noticed that, on a few
test assumptions, when `resolving`, we were not converting values
back to it's original form (`CtxDict` -> `dict`, `CtxList` -> `list`
and `Value` -> _). So, I have added `node.value` property, as it is
a complement of the above fix.

This caused a ripple effect for the changes in the API as `foreach`
now resolved and changed the data to the original form,
which prevents us from tracking the data later in the "field".
So, `resolve`/`select` got a real `unwrap` option that should
reliably work for all kinds of `Node` objects.
2020-11-07 03:02:24 +02:00
Paweł Redzyński f4ee0e16b4 plots/metrics: fix some communication disrepancies (#4837)
* plots/metrics: unified parsing errors communication

* plots/metrics: unify no metrics/plots messaging

* fixup

* fixup
2020-11-07 02:56:14 +02:00
Ruslan Kuprieiev 4531aa34ca dvc: use DirInfo instead of dicts/lists (#4851)
Currently we are converting dir_info to/from lists all the time.
The reason is that dir_info is stored as list of dicts in *.dir files,
but that makes it hard to work with. In addition to that, we will likely
be changing .dir file format in the near future #829, so we need to
abstract away dir_info into something that we won't care how it will be
stored on disk.

Related #3256
Related #4847
2020-11-07 02:55:05 +02:00
Peter Rowlands (변기호) 80e181f2fc experiments: add dvc exp gc (#4803)
* experiments: add `dvc exp gc`

* tests: add exp gc tests

* add exp gc command test

* fix review issue
2020-11-06 19:05:41 +09:00
Peter Rowlands (변기호) c85a46cdda experiments: add checkpoint output type (#4845)
* output: add checkpoint output type

* stage: handle checkpoint outs

* run/repro: handle checkpoint outs

* experiments: use checkpoint output type in tests

* tests: update run command tests
2020-11-06 14:44:21 +09:00
Ruslan Kuprieiev 89bf7fce13 dvc: limit aws-sam-translator version for moto (#4853)
moto has aws-sam-translator as an indirect dependency that since 1.29.0
started to have a public package
that causes conflicts in flufl.lock when it tries to use atpublic
package.

```
tests/func/test_add.py:21: in <module>
    from dvc.main import main
dvc/main.py:7: in <module>
    from dvc import analytics
dvc/analytics.py:16: in <module>
    from dvc.lock import Lock, LockError
dvc/lock.py:8: in <module>
    import flufl.lock
/opt/hostedtoolcache/Python/3.6.12/x64/lib/python3.6/site-packages/flufl/lock/__init__.py:3: in <module>
    from flufl.lock._lockfile import (
/opt/hostedtoolcache/Python/3.6.12/x64/lib/python3.6/site-packages/flufl/lock/_lockfile.py:42: in <module>
    from public import public
E   ImportError: cannot import name 'public'
```

Oddly enough, this seems to also been causing pylint issues. public is a
terrible name for a package SMH
2020-11-06 04:10:54 +02:00
Ruslan Kuprieiev 42d50f084f gs: don't forget to include file size (#4852) 2020-11-06 02:59:05 +02:00
Paweł Redzyński edd8344659 plots: fix plots path when calling from different dir (#4843) 2020-11-05 08:11:24 +01:00
Ruslan Kuprieiev 4b17685351 cache: refactor dir_info conversion (#4844) 2020-11-04 21:42:14 +02:00
Ruslan Kuprieiev e1b82c5222 dvc: add size/nfiles for deps/outs (#4836)
* dvc: add size for deps/outs

Related to #3256

* dvc: add nfiles for deps/outs

* dvc: put size/nfiles into the hash_info
2020-11-04 19:22:57 +02:00
Saugat Pachhai e9a9415fbf refactor: move interpolation logic inside the Context (#4840)
* Move interpolation logic to the Context

* Don't unwrap by default on `Context.select`
2020-11-04 17:04:40 +05:45
Sami Jawhar 6494bd0151 Plots: Better confusion matrix, and normalized version to (#4775)
* Add normalized confusion plot, and add text labels

* Impute missing XY combinations
2020-11-04 11:33:11 +01:00
Liew XunandSaugat Pachhai d1441ee16a http: raise error on unsuccessful http statuses on exists (#4785)
* http: added check for HTTP status code 404

Fixes #4288

* http: add unit test and minor change

In tests_exists, changed to raise a HTTPError from dvc instead.

Fixes #4288

* http: removed fixtures in test for HTTPTree.exists

Fixes #4288

* test: add comments on why `raw` is required on the mock

Co-authored-by: Saugat Pachhai (सौगात) <[email protected]>
2020-11-04 14:43:42 +05:45
Ruslan Kuprieiev 7da3de451f build: test built binary (#4839) 2020-11-03 23:30:41 +02:00
Ruslan Kuprieiev 4a3f06df3a oss: require pycryptodome<3.9.9
3.9.9 doesn't have windows wheels https://github.com/Legrandin/pycryptodome/issues/465
2020-11-03 20:28:44 +02:00
Ruslan Kuprieiev 2225764438 tests: set subprocess._active to None (#4818)
To better reflect https://github.com/python/cpython/pull/14360
2020-11-03 19:31:04 +02:00
Peter Rowlands (변기호) 644444b5a7 experiments: fix checkpoint checkout issues (#4823)
* checkout: add option to suppress missing file warnings

* experiments: fix checkpoints branch checkout issues

* experiments: _get_branch_containing should not change scm checkout
2020-11-03 22:21:54 +09:00
Saugat Pachhai 60b69b93cb Add flaky-service (#4834)
Use master branch for flaky-service

Generate TAP results during pytest
2020-11-03 18:41:10 +05:45
Saugat Pachhai fec1499c67 Move interpolation logic to the Context (#4833) 2020-11-03 17:42:28 +05:45
Saugat Pachhai a38941b47e Refactor context set/alias to Context class (#4831) 2020-11-03 17:06:46 +05:45
Saugat Pachhai 6a9ab9cdfb dvc.yaml: implement set keyword (#4757) 2020-11-02 20:32:07 +05:45
Saugat Pachhai e211423398 Implement foreach ... in loop in dvc.yaml (#4734)
* Implement foreach...in loop in dvc.yaml

* fix tests
2020-11-02 18:56:23 +05:45
Peter Rowlands (변기호) 96cdd073bd diff: handle empty output status (#4826) 2020-11-02 21:16:29 +09:00
simonlousky 59d5c73c5e increased http timeout to 60 seconds (#4820) 2020-11-01 13:19:55 +02:00
Kevin Lloyd Bernal 58bed0e9ba import: add --no-exec param (#4817)
To be consistent with the same --no-exec param in import-url.

Fixes #4567.
2020-10-31 20:03:33 +02:00
Ruslan Kuprieiev 92752e0392 dvc: upgrade dulwich to 0.20.11
This one has wheels for python 3.9
2020-10-30 19:16:43 +02:00
João Vitor 4a7bb49bb0 command: add dvc push instruction after dvc repro (#4737)
* command: add dvc push instruction after dvc repro

Adds a logger.info() statement to remind the user to run dvc push after
running dvc repro.

Fixes #2359

* command: change dvc push instruction for dvc repro

* command: Use backticks and else in dvc repro log
2020-10-30 15:47:00 +01:00
Peter Rowlands (변기호) 3d58f4b8ce git: move branch iteration functionality from experiments into scm.git (#4810)
* git: move branch iteration functionality from experiments into scm.git

* add branch_revs unit test
2020-10-30 19:06:35 +09:00
Peter Rowlands (변기호)andPaweł Redzyński f827d641d5 add: warn on cache link errors instead of failing (#4801)
* add: warn on cache link failure instead of error

* add test case

* fix linted string

Co-authored-by: Paweł Redzyński <[email protected]>

Co-authored-by: Paweł Redzyński <[email protected]>
2020-10-30 02:01:11 +02:00
Paweł Redzyński 9940540917 metrics/plots: test metrics collection on no target on revision (#4789)
* metrics: include revision information when collecting metrics files

* plots: test warning message on no plot on revision

* plots/metrics: move to one package, introduce common tests

* tests: fix utils placement
2020-10-30 02:00:09 +02:00
Ruslan Kuprieiev 32fa8e33c4 git: use dulwich for .gitignore (#4809)
In my tests `Git._ignore` is taking around ~65seconds for the whole test
suit(~50K invocations). The reason is that `gitpython` is launching a real
`git check-ignore` process, that takes time and resources.

Switching to `pygit2` or `dulwich` makes it drop to ~5seconds, saving us
almost a minute of the test run time and a lot of resources by not
launching any processes. I've chosen `dulwich` over `pygit2` here
because it is much more pythonic and as long as the performance is
on-par with `pygit2` it is better for us to use `dulwich`.
2020-10-30 01:01:58 +02:00
Peter Rowlands (변기호) 911534116e experiments: fix stash ordering bug (#4805) 2020-10-29 18:01:34 +09:00
Ruslan Kuprieiev 9b136f0c3f pyarrow: upgrade to 2.0.0 (#4795) 2020-10-27 19:14:30 +02:00
Ruslan Kuprieiev 411b388960 dvc: bump to 1.9.1 2020-10-27 17:33:27 +02:00
Juliano Efson Sales e6016ca202 tests: testing when overwriting files (#4738) 2020-10-27 15:52:16 +02:00
Peter Rowlands (변기호) b2495aa230 add: fix issue when adding already tracked symlinked files (#4778)
* LocalOutput: paths symlinked outside of git can't be gitignored

* utils: contains_symlink_up_to should only check parent dirs on add

* tests: update test_add symlink cases

* add: forbid adding symlinked dirs or files inside symlinked dirs

* update tests

* add troubleshooting link
2020-10-27 15:39:01 +02:00
f4522760ba --jobs now takes effect on imported data (#4765)
* `--jobs` now takes effect on imported data

fix #4692

* Tests for this change

* Solve issue on python3.6 and 3.7

* Update tests/unit/test_external_repo.py

* Update tests/unit/test_external_repo.py

* Update tests/unit/test_external_repo.py

* Update tests/unit/test_external_repo.py

* tests: fix formatting

Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-10-27 01:21:51 +02:00
Ruslan Kuprieiev 0736c64188 gha: don't create gs creds file if no creds 2020-10-27 00:23:34 +02:00
Paweł Redzyński 6cf194f85f plot: fix no metrics data detection (#4764) 2020-10-26 20:05:55 +02:00
Paweł Redzyński 5038fd225b plots/metrics: support show for no repo (#4761) 2020-10-26 20:04:49 +02:00
Ruslan Kuprieiev bfecd7d8e8 gha: add gs creds (#4791)
* gha: add gs creds

Related to #4418

* tests: gs: don't use gcloud
2020-10-26 19:20:58 +02:00
Ruslan Kuprieiev a5bdfa90f0 gha: add aws creds (#4790)
Related to #4418
2020-10-26 17:02:07 +02:00
Paweł Redzyński 4d75e75631 test_update_import: roll back skip (#4781) 2020-10-26 15:28:30 +02:00
Tomás Farías Santana f8ba5daae2 Replace dict calls for literals (#4784)
* Replace dict calls for literals

* Pin flake8-comprehensions version to 3.3.0
2020-10-25 19:51:22 +02:00
Sami Jawhar 831e85682e Experiments: allow setting of list parameters (#4769)
* Allow setting of list parameteres

Check if dict is Mapping or list before using .get()

* Added test for dvc exp run with list paramater

* Add more tests for string parameter modification
2020-10-23 14:35:17 +09:00
Saugat Pachhai ec05e6026f Display coverage report on the GHA log and measure branch coverage (#4760)
* Display coverage report on the terminal and Measure branch coverage

* Add source path

* Install wheel, run in editable mode
2020-10-22 20:28:50 +03:00
Ruslan Kuprieiev 0669d8d885 dvc: bump to 1.9.0 2020-10-22 20:22:18 +03:00
Ruslan Kuprieiev 2c7d89939c gdrive: get rid of empty file hack (#4774) 2020-10-22 20:07:55 +03:00
Saugat Pachhai e8c4924997 Make codecov push results on every run (#4754)
* Make codecov push results on every run

* codecov: do not comment on pull requests
2020-10-22 17:17:26 +03:00
Samuel Neugber 2747e77012 Escape spaces in file paths when running commands on SSH remote (#4767)
* Escape file paths for calculation of md5 hashes on ssh remote

* Add tests for hardlink and improved md5 test with PropertyMock

* Add additional special characters to check for fix of issue #4628 and fix formatting

* Fix linting errors

* Move tests to correct file and use injected ssh_connection instead of setting it up from scratch
2020-10-22 13:57:18 +03:00
Peter Rowlands (변기호) 87f1c42acf experiments: wrap column entries with rich.Text (#4771) 2020-10-22 16:40:08 +09:00
Peter Rowlands (변기호) 8a5dc6a781 init: move welcome message from Repo.init to command.init (#4759)
* repo: add quiet flag for Repo.init

* move welcome message to command/init
2020-10-22 12:49:48 +09:00
Paweł Redzyński 33ece011fb TestCmdCacheDir: roll back darwin skip (#4673) 2020-10-21 23:36:16 +03:00
Peter Rowlands (변기호) 79232dfedf checkpoints: --continue from last run experiment by default (#4758)
* checkpoints: `--continue` with no args resumes most recent experiment

* add test for --continue :last
2020-10-21 17:25:16 +09:00
Saugat Pachhai aa7b4c6340 Add tests for resolving stage data (#4753)
* Add test, and fix resolving of paths for params

Improve merging of vars and imported params, improve tracking
Resolve wdir correctly

* Fix windows relpath

* isort imports
2020-10-21 05:47:48 +05:45
Saugat Pachhai 668002cb01 Revert "Track flaky tests using Google's service (#4749)" (#4755)
This reverts commit 7527a220f6.
2020-10-20 08:19:00 -07:00
Peter Rowlands (변기호) 25efd0efe8 checkpoints: allow missing outputs on checkout and stage.run (#4750) 2020-10-20 21:44:01 +09:00
Saugat Pachhai b0ae5e2251 Support parametrization from params or vars section in dvc.yaml (#4751)
* Implement importing from params

* Change varname

* Add tests

* fix pylint issues
2020-10-20 04:16:13 -07:00
Ruslan Kuprieiev e48055077d plots: don't allow in non-dvc repo (#4745) 2020-10-20 11:31:57 +03:00
Saugat Pachhai 7527a220f6 Track flaky tests using Google's service (#4749)
* Track flaky tests using Google's service

https://flaky-dashboard.web.app
More info: https://github.com/GoogleCloudPlatform/flaky-service

* Increment version
2020-10-19 23:24:11 -07:00
Peter Rowlands (변기호) feaa5cfcb2 experiments: fix checkpoints sorting issues in dvc exp show (#4740)
* experiments: order experiment (branches) by timestamp

* checkpoints: sort checkpoint branches by tip commit values

* remove sort logic from dvc.scm
2020-10-20 14:54:43 +09:00
Saugat Pachhai 10ae13132d Use lxml wheel instead of legacy setup.py builds (#4748)
`lxml` now provides wheel for Python3.9. 

Also, enable windows test for Python3.9
2020-10-19 22:23:51 -07:00
Harsh Patel 924323f54f lock: added troubleshooting doc link in error message (#4325) (#4721)
* lock: added troubleshooting doc link in error message (#4325)

* updated lock error message (iterative#4325)
2020-10-19 20:47:08 +03:00
Alexei Tenitski 235d4c9a94 #1618 Custom message when cache and remote are in sync (#4694)
* #1618 Custom message when cache and remote are in sync

* Output the correct remote name during dvc status

* Simplify logic
2020-10-19 20:38:05 +03:00
Musa AtlıhanandPeter Rowlands 4b6ebc7527 make pull --run-cache work with local remote having relative path (#4670)
* Fixes #4637

* use PathInfo instead of Path

* catch ValueError with PathInfo.relative_to and fallback to dvc.utils.relpath

* cleanup

* add test

Co-authored-by: Peter Rowlands <[email protected]>
2020-10-19 17:53:01 +09:00
Peter Rowlands (변기호) 699602f4d0 experiments: use general format for floating pt numbers (#4730) 2020-10-19 15:26:29 +09:00
Peter Rowlands (변기호) 10a2eeb2b8 checkpoints: create final checkpoint commit after repro when needed (#4715)
* repo: make Repo.scm a cached attribute

* checkpoints: create final checkpoint after repro when needed

* use correct exception rev in _collect_and_commit
2020-10-19 15:26:06 +09:00
Peter Rowlands (변기호) b9f547637c checkpoints: add --reset option (#4731)
* experiments: add --reset option to reset/restart checkpoint experiments

* add CheckpointExistsError internal exception

* update tests
2020-10-19 15:25:08 +09:00
Juliano Efson SalesandRuslan Kuprieiev 052b15a2a8 tests/remote: adding tests for real oss (#4735)
* tests/remote: adding tests for real oss

the new test suggests #4633 is a bug in the emulator.

* Update tests/func/test_data_cloud.py

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

* Update tests/remotes/oss.py

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

* tests: correcting small lint issue

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-10-18 16:21:51 +03:00
Jonny Browningandbrowningjp 68220cbea0 Added 'doctor' as an alias for 'version' (#4722)
* Added 'doctor' as an alias for 'version'

* simplified doctor alias

Co-authored-by: browningjp <[email protected]>
2020-10-17 11:10:03 +03:00
Ruslan Kuprieiev 21a6be7c58 Delete .restyled.yaml
Since we've decided to stop using it. Also disabled in the repo settings.
2020-10-15 15:07:58 +03:00
Paweł Redzyński 2b90d7dfaf tests, packages: roll back environmental packages setup (#4728) 2020-10-15 14:19:25 +03:00
Peter Rowlands (변기호) 9d7e483599 experiments: fix git stash mtime race condition (#4727) 2020-10-15 13:26:17 +03:00
Paweł Redzyński 8f8c561d1b test_api: roll back xfail (#4660) 2020-10-15 10:40:53 +02:00
Ruslan Kuprieiev 32cef509b7 run-cache: don't save always_changed stages (#4719)
Fixes #4335
2020-10-14 17:43:16 +03:00
Saugat Pachhai 6a79111047 Parametrization support on dvc.yaml (#4679)
* Custom parametrization support

* log generated data

* Add tests
2020-10-14 05:32:05 -07:00
Peter Rowlands (변기호) 09e51e655d checkpoints: cleanup --continue usage (#4716)
* warn if rev provided with --continue is ambiguous
* always provide tip of branch when suggesting to use --continue
2020-10-14 19:12:57 +09:00
Peter Rowlands (변기호) 4aa8fd0fbb tests: mark test_extend_branch as flaky (#4718) 2020-10-14 19:12:32 +09:00
Peter Rowlands (변기호) 3cebd98c54 tests: fix test_remote::test_push_order (#4714)
* tests: fix test_remote::test_push_order

* review fixes
2020-10-14 18:40:04 +09:00
Ruslan Kuprieiev b11fe83828 dvc: bump to 1.8.4 2020-10-13 23:57:29 +03:00
Peter Rowlands (변기호) cc8d0ef43f experiments: support exp run --checkpoint for baseline commits (#4701)
* handle untracked files when `git reset --hard` in experiments workspace

* error on checkpoint branch already exists

* terminate stage process on checkpoint errors

* update tests

* cleanup error message
2020-10-13 23:10:08 +03:00
Paweł Redzyński 3e011d70d5 github actions: windows: fix packaging (#4711) 2020-10-13 21:31:48 +02:00
Ruslan Kuprieiev 0649242578 dvc: bump to 1.8.3 2020-10-13 13:17:22 +03:00
Paweł Redzyński 549f342db9 gha: fix github token providing for assets upload (#4703) 2020-10-12 18:18:20 +03:00
Saugat Pachhai 4e53491f5c Use released version of Python3.9 on CI (#4704) 2020-10-12 20:24:28 +05:45
Paweł Redzyński 0da1664f3f gha: get rid of unnecessary if (#4702) 2020-10-12 13:33:15 +03:00
Jorge OrpinelandRuslan Kuprieiev 3b6a1c441e term: "metrics" plural in output messages (#4699)
* api: update to latest definitions to match iterative/dvc.org/pull/908

* Update dvc/api.py

* metrics show: update -h output to match docs
per https://github.com/iterative/dvc.org/pull/1014/commits/2c3452119545987f77b764e4aa6fc94d051092a7

* api: update docstrings to match iterative/dvc.org/pull/908

* api: refactor DVC repo check in get_url, and document it

* dvc: cosmetic edits as I explored exceptions that api functions may raise

* api: copy default info to read() docstring from open()
per https://github.com/iterative/dvc/pull/3426#pullrequestreview-366949501

* api: improve open() docstring for clarity and add example
per https://github.com/iterative/dvc/pull/3426#pullrequestreview-366956856

* api: remove unnecessary info from get_url docstring
per https://github.com/iterative/dvc/pull/3426#discussion_r386203064

* api: produces->generated in open() docstring

* api: simplify open docstring
per https://github.com/iterative/dvc/pull/3426#discussion_r388218147

* term: "metrics" plural in output messages
https://github.com/iterative/dvc.org/pull/1848#pullrequestreview-506217071

* typo

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-10-12 12:51:57 +03:00
Ruslan Kuprieiev ca490f699e ls: pass repo_tree instead of repo (#4700) 2020-10-12 12:37:19 +03:00
omar.zhouandzwh 59105ec639 Fixed the bug #4251, and fixed a minor bug which causes of OS default character. (#4646)
* bug fixed
bug description:
1, environment: OS which default character is not 'UTF-8'. For example windows 10 which default character is 'gbk'.
2, operation:
python setup.py install
Traceback (most recent call last):
  File "setup.py", line 149, in <module>
    long_description=open("README.rst", "r").read(),
UnicodeDecodeError: 'gbk' codec can't decode byte 0xa2 in position 41: illegal multibyte sequence

* bug #4251 fixed
https://github.com/iterative/dvc/issues/4251

* bug #4251 fixed
https://github.com/iterative/dvc/issues/4251

* Revert "bug #4251 fixed https://github.com/iterative/dvc/issues/4251"

This reverts commit f524c727

* Revert "bug #4251 fixed https://github.com/iterative/dvc/issues/4251"

This reverts commit fa5db477

* Fix bug #4251
We can check if the source exists by using `dep.exists`

Co-authored-by: zwh <[email protected]>
2020-10-12 09:00:16 +03:00
1d550f35af command: print the current cache directory when no cmd flags are provided (#4613)
* command: print the current cache directory when no cmd flags are provided
tests: corrected the test for cache dir cmomand

comamnd 'dvc cache dir' now prints the current cache dir
when no cmd flags are provided and modified existing test for
the same.

Fixes #1998

Signed-off-by: Girish Joshi <[email protected]>

* command: updated validation for args.value and args.unset flag
according to current convention in dvc/command/cache.py

Signed-off-by: Girish Joshi <[email protected]>

* Update dvc/command/cache.py

* dont forget logger

Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-10-12 08:51:38 +03:00
Paweł Redzyński fba94a4569 tests build: buil on push/pr to master (#4680) 2020-10-10 00:22:08 +03:00
Frederik Schubert 0e257adc63 remote: add WebDAV dependency (#4690)
This adds the dependency for WebDAV and enables calls like `import-url
remote://some-webdav/data`

Related to #4374
2020-10-10 00:07:34 +03:00
Frederik Schubert 8b28a268e8 remote: set dependency name for WebDAV (#4691)
When executing `dvc import-url remote://some-webdav/data`, the name of
the WebDAV dependency in the created stage for data used to be `None`.
This resulted in the following error:
```
Traceback (most recent call last):
  File "/home/schubert/projects/dvc/dvc/main.py", line 76, in main
    ret = cmd.run()
  File "/home/schubert/projects/dvc/dvc/command/imp_url.py", line 14, in run
    self.repo.imp_url(
  File "/home/schubert/projects/dvc/dvc/repo/__init__.py", line 51, in wrapper
    return f(repo, *args, **kwargs)
  File "/home/schubert/projects/dvc/dvc/repo/scm_context.py", line 4, in run
    result = method(repo, *args, **kw)
  File "/home/schubert/projects/dvc/dvc/repo/imp_url.py", line 54, in imp_url
    stage.run()
  File "/home/schubert/miniconda3/tmp/envs/some-project/lib/python3.8/site-packages/funcy/decorators.py", line 39, in wrapper
    return deco(call, *dargs, **dkwargs)
  File "/home/schubert/projects/dvc/dvc/stage/decorators.py", line 36, in rwlocked
    return call()
  File "/home/schubert/miniconda3/tmp/envs/some-project/lib/python3.8/site-packages/funcy/decorators.py", line 60, in __call__
    return self._func(*self._args, **self._kwargs)
  File "/home/schubert/projects/dvc/dvc/stage/__init__.py", line 450, in run
    self.save()
  File "/home/schubert/projects/dvc/dvc/stage/__init__.py", line 395, in save
    self.md5 = self.compute_md5()
  File "/home/schubert/projects/dvc/dvc/stage/__init__.py", line 388, in compute_md5
    m = compute_md5(self)
  File "/home/schubert/projects/dvc/dvc/stage/utils.py", line 167, in compute_md5
    return dict_md5(
  File "/home/schubert/projects/dvc/dvc/utils/__init__.py", line 118, in dict_md5
    return dict_hash(d, "md5", **kwargs)
  File "/home/schubert/projects/dvc/dvc/utils/__init__.py", line 113, in dict_hash
    byts = json.dumps(filtered, sort_keys=True).encode("utf-8")
  File "/home/schubert/miniconda3/tmp/envs/some-project/lib/python3.8/json/__init__.py", line 234, in dumps
    return cls(
  File "/home/schubert/miniconda3/tmp/envs/some-project/lib/python3.8/json/encoder.py", line 199, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "/home/schubert/miniconda3/tmp/envs/some-project/lib/python3.8/json/encoder.py", line 257, in iterencode
    return _iterencode(o, 0)
TypeError: '<' not supported between instances of 'str' and 'NoneType'
```
2020-10-10 00:05:18 +03:00
Paweł Redzyński 09f9506ca0 github actions: migrate to envrionmental files (#4684) 2020-10-09 11:33:06 +03:00
Peter Rowlands (변기호) f20adf5e0f experiments: add --show-json for exp show (#4689) 2020-10-09 15:07:56 +09:00
Paweł Redzyński 9f90c806d5 params, plots, metrics: unify collection (#4603)
* params, plots, metrics: unify collection

* collect: split into smaller parts
2020-10-09 06:26:11 +03:00
Peter Rowlands (변기호) c16d3fa43d experiments: migrate dvc repro -e to dvc exp run (#4677)
* experiments: migrate from `dvc repro -e` to `dvc exp run`

* tests: use experiments.run instead of reproduce

* update `repro` and `exp run` commands
2020-10-09 00:26:31 +03:00
Ruslan Kuprieiev 3ceef7dbf1 dvc: get rid of ExternalGitRepo (#4685) 2020-10-08 22:04:48 +03:00
Peter Rowlands (변기호) 34804b5636 experiments: fix checkpoints callback bug when pipeline includes data (#4678) 2020-10-08 17:47:42 +09:00
Ruslan Kuprieiev 060fc4fbb7 state: use granularly (#4676)
* state: add simple reentrance

* state: pass Repo instead of LocalCache

* state: use granularly

Users could still load state preemptively if they want to bulk up
the operations, but other than that tree/cache will just load it
when they need it.
2020-10-08 08:36:14 +03:00
Ruslan Kuprieiev 4496dbf5b5 release-drafter: add ui to enhancements 2020-10-08 03:01:38 +03:00
Ruslan Kuprieiev 334afebd31 dvc: bump to 1.8.2 2020-10-08 02:57:22 +03:00
Ruslan Kuprieiev 775e1e8374 repo: don't create .dvc in read-only repos (#4674)
* lock: use ABC

* lock: add LockNoop

* plots: remove unused code

* plots: create templates only on init

* dvc: don't create .dvc in read-only repos

* repo: explicitly init LockNoop
2020-10-07 23:51:14 +03:00
Paweł Redzyński 548c2dcc1f test_str_workdir_outside_repo: roll back skip on windows (#4672) 2020-10-07 19:15:14 +03:00
Ruslan Kuprieiev 15b1e7072b gha: don't build packages for PRs
Related to #4468
2020-10-06 20:16:37 +03:00
Peter Rowlands (변기호) f0a729df4d experiments: checkpoints proof of concept (#4591)
* stage: make runs checkpointable

* api: add make_checkpoint API call

* tests: add test for checkpointed stage

* experiments: implement checkpoints callback chain

* initial working state

* use .dvc/tmp for checkpoint signal file location

* force repro for checkpoint experiments

* checkout: add flag to allow missing persistent outputs

(i.e. not yet created)

* use allow_persist_missing on checkpoint experiment repro

* executor: force checkout before checkpoints experiment repro

* move checkpoint commands to `dvc exp run`

* support resuming checkpoint runs with --continue

* fix git bugs

* experiments: include checkpoint commits in `exp show`

* use 1sec sleep timers

* fix apply_workspace test conflict

* tests: add test for `dvc exp run`

* cleanup signal file behavior

* fix bool conversion

* tests: add tests for checkpoint and checkpoint_continue

* fix styling

* fix checkpoint stage test

* fix checkpoint monitor thread condition/notification
2020-10-06 15:34:11 +03:00
Ruslan Kuprieiev 7424f22e2a repro: support --pull (#4538)
* repro: support --pull

This option allows run-cache to pull the cache for the outputs
when restoring a stage instead of pulling everything at once during
`dvc pull --run-cache`.

Fixes #4223

* repro: adjust --pull help
2020-10-06 04:22:19 +03:00
Ruslan Kuprieiev 9ee5018c63 tests: gdrive: skip if env var is empty 2020-10-06 01:43:39 +03:00
Ruslan Kuprieiev 026047aa80 gha: set GDRIVE_CREDENTIALS_DATA 2020-10-05 23:23:02 +03:00
Ruslan Kuprieiev 3878c50ea8 yaml: wrap ruamel's DuplicateKeyError (#4668)
* yaml: wrap ruamel's DuplicateKeyError

Unfortunately ruamel's exception doesn't contain much info that we could
reuse, so we can only reasonably extract the exc.problem and have
something like:

```
ERROR: failed to obtain data status - unable to read: 'dvc.yaml', found
duplicate key "metrics" with value "[]" (original value: "[]")
```

Fixes #4664

* status: remove unneeded error prefix
2020-10-05 23:02:59 +03:00
Ruslan Kuprieiev 3ffb6320ac dvc: print version to debug log (#4666)
Related to #4664
2020-10-05 21:04:31 +03:00
Ruslan Kuprieiev 52fe76cefc metrics: diff: make table more intuitive (#4655)
* metrics: diff: make table more intuitive

Currently we have a "diff not supported" marker in the table, that is
used whenever dvc is not able to diff the values (e.g. no old one, no
new one or format error). It is quite ugly and it is much more intuitive
to just show both old and new values and now show that marker at all, as
if there is no old/new value it is intuitive that we can't calculate the
change. Next step is to add old/new "bad format"/etc markers for
unsupported types or malformed/missing metric files.

E.g., before:

```
Path          Metric    Value    Change
metrics.json  foo       1.2      diff not supported
```

after this PR:

```
Path          Metric    Old    New    Change
metrics.json  foo       None   1.2
```

* metrics/params: use em dash in diff table
2020-10-05 19:37:34 +03:00
Guillermo Blanco d0e1c88792 completions: fix run command dependencies autocompletion (#4659)
* completions: fix run command dependencies autocompletion

Fixes #4552

* completions: fix run command outputs autocompletion

Fixes #4552

* completions: fix run command parameters autocompletion

Fixes #4552
2020-10-05 15:28:59 +03:00
Peter Rowlands (변기호) fcdb503b4e diff/dvctree: optimize dir cache access (#4626)
* dvctree: use string paths for performance reasons in
_get_granular_checksum()

* dvctree: cache most recently accessed metadata

* dvctree: cache output dir cache during walk() calls

* diff: use get_file_hash directly instead of get_hash when appropriate

* dvctree: cache most recen dir_cache hashes in _get_granular_checksum

* revert metadata cache changes

- mostly unnecessary after granular checksum changes, will be better
  addressed by the future exception handling updates

* fix windows path issue

* HashInfo: store HashInfo.dir_info as a dict instead of list

* DvcTree: use hash_info.dir_info for getting granular file hashes

* use str relpath

* use correct dir_info hash type

* fix windows paths
2020-10-03 03:54:17 +03:00
Ruslan Kuprieiev 3d2493a391 dvc: bump to 1.8.1 2020-10-01 11:15:30 +03:00
Saugat PachhaiandRuslan Kuprieiev fc6999ad17 Run CI for Python3.9 (#4647)
* Run CI for Python3.9, skips hdfs tests for 3.9

Also, `dvc[hdfs]` install will be a no-op till we have
a pyarrow wheel for 3.9.

Note that this does not add an entry to the classifier
claiming it supports 3.9, that will be added when 3.9
gets released (or, wait for pyarrow to be released).

* Skip HDFS related tests on Python3.9

Also, fix the python version regex for `3.9.0b5` like versions.

* Exclude python3.9 for Windows

libxml does not have wheels for Python39.
We do compile them on Linux and Mac

* Update tests/unit/test_info.py

* Use realpath for gitignore

Windows tests for Python3.8 was failing
intermittently.

Note: Also removed the realpath wrap around self.root_dir as it's already a
realpath when `Git` is instantiated.

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-10-01 10:41:39 +03:00
Saugat Pachhai e0b3e98039 api.open: fix error if uncached file inside a dir is accessed (#4652)
Wrap path in Repo.open_by_relpath() with PathInfo.
Closes #4650
2020-10-01 10:40:56 +03:00
Casper da Costa-Luis 04bd460136 bump shtab dependency (#4651)
Fixes bash>=4.4 completion.
See https://github.com/iterative/shtab/pull/23
2020-10-01 02:43:49 +03:00
Ruslan Kuprieiev d7a35b9852 gha: enable hdfs tests (#4644) 2020-09-30 05:43:34 +03:00
Paweł Redzyński ab22118cd4 metrics, plots: update cmd targets help (#4620) 2020-09-30 04:55:00 +03:00
Raymond GasperandRuslan Kuprieiev b0dbcdcdb6 metrics show: allow falsey values (#4642)
* metrics show: allow falsey values

Fixes #4630

* metrics show: bug in logging to console. Fixes #4630

* repro: fix incorrect usage of _show_metrics

* fix formatting/styling

* metrics: add a simple func test

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-09-30 04:53:06 +03:00
gsvoltandRestyled.io fb756c0538 application_process: scm: Git: _add_entry_to_gitignore: writes unique entry to .gitignore (#4624)
* scm:  Git: _add_entry_to_gitignore: writes unique entry to .gitignore

Fixes #4327

* Restyled by black

Co-authored-by: Restyled.io <[email protected]>
2020-09-30 04:02:44 +03:00
Ruslan Kuprieiev 00267706b7 dvc: bump to 1.8.0 2020-09-30 03:39:33 +03:00
Ruslan Kuprieiev 1fcb94dee8 hdfs: add a progress bar (#4643) 2020-09-30 03:36:50 +03:00
Ruslan Kuprieiev dc6abba679 gdrive: fix comment formatting 2020-09-30 03:10:39 +03:00
Ruslan Kuprieiev fdc455a08f gdrive: use PyDrive2 issue link instead of PyDrive 2020-09-29 23:29:35 +03:00
Ruslan Kuprieiev 7b590e7c5d gdrive: revert back to the hack for uploading empty files (#4632)
* gdrive: bring back the progress workaround

* WIP cloud: test empty files
2020-09-29 13:49:49 +03:00
Ruslan Kuprieiev e3a7857b13 repo: move index_dir to RemoteIndex (#4631) 2020-09-29 03:32:33 +03:00
Ruslan Kuprieiev 644c2df5e8 plots: fix typo 2020-09-29 00:55:38 +03:00
Ruslan Kuprieiev de24794cd5 repo: move templates to plots (#4629) 2020-09-29 00:38:11 +03:00
Ruslan Kuprieiev 1ea8cc9e54 metrics/plots: support non-dvc repos (#4627) 2020-09-28 23:06:22 +03:00
Pavel DitenbirandRuslan Kuprieiev 738dfd49cf import: add '--file' option (#4622)
* import: add '--file' option

Fixes #4093

* Update dvc/command/imp.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-09-27 20:19:08 +03:00
Guillaume COTER a5ab01390f remote[ssh]: Add the parameter allow_agent (#4623)
This parameter is passed to paramiko.
See http://docs.paramiko.org/en/stable/api/client.html?highlight=allow_agent#paramiko.client.SSHClient.connect for more details.

Fixes #4186
2020-09-27 01:09:00 +03:00
David RobertsandRuslan Kuprieiev a664059eff status: Show friendly message for empty project. (#4609)
* status: Show friendly message for empty project.

When there are no stages in the repo, show a friendly message to that
effect and display a getting-started URL.

Fixes #4395.

* Fix style check failing due to line too long. (E501)

* Use format_link(…) as suggested by @skshetry, vs {blue}/{nc}.

Since I'd pulled the old syntax out of repo/init.py, I've fixed that up
as well so no one makes that mistake again. Note that this adds <>s around
the link text, unlike before:

+---------------------------------------------------------------------+
|                                                                     |
|        DVC has enabled anonymous aggregate usage analytics.         |
|     Read the analytics documentation (and how to opt-out) here:     |
|             <https://dvc.org/doc/user-guide/analytics>              |
|                                                                     |
+---------------------------------------------------------------------+

This is probably fine, since if we're going to have a standard link format
we might as well stick with it consistently. If we don't like it, we can
change format_link() I guess.

* Update dvc/command/status.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-09-25 18:05:20 +03:00
Robert Lowry c94ab359b6 cli: add --cd flag to change directories before executing commands (#4566)
* cli: add -C flag to change directories before executing commands

Fixes #4025

* add deprecation notice to dvc repro help message
2020-09-25 04:20:22 +03:00
Ruslan Kuprieiev 9643ff7229 dvc: bump to 1.7.9 2020-09-25 02:30:14 +03:00
Ruslan Kuprieiev 8da40af3e4 ssh: download: don't use self.path_info (#4619) 2020-09-25 02:27:55 +03:00
Peter Rowlands (변기호) 36935bdce2 experiments: fix --all-tags/--all-branches bug (#4612)
* brancher: add sha_only option to omit branch/tag names

* experiments: resolve branches/tags into commit objects during `exp show`
2020-09-24 22:48:57 +03:00
Ruslan Kuprieiev 2f030e870b Update packages.yaml 2020-09-24 18:53:50 +03:00
Ruslan Kuprieiev a2adc956b9 dvc: bump to 1.7.8 2020-09-24 18:46:36 +03:00
Ruslan Kuprieiev deaa27dde2 gha: don't build packages when not needed 2020-09-24 18:44:10 +03:00
Ruslan Kuprieiev b784216f68 dvc: bump to 1.7.7 2020-09-24 18:38:07 +03:00
Peter Rowlands (변기호) 32bb46f34f experiments: support cloning parent in detached-head state (#4611)
* experiments: account for cloning detached-head parent repo

* add test for detached-head parent
2020-09-24 18:36:51 +03:00
Ruslan Kuprieiev 2cbd46ba75 gha: publish packages only on released release 2020-09-24 18:31:05 +03:00
Ruslan Kuprieiev 954e4784ab dvc: bump to 1.7.6 2020-09-24 17:45:53 +03:00
Ruslan Kuprieiev 62edbaf07c ssh: install paramiko with invoke (#4617)
Paramiko has recently added support for `Match`
https://github.com/paramiko/paramiko/issues/717
which resulted in some users getting an import error about `invoke`
module, as it is not a default dependecy for paramiko.
@Pseudomanifold reported that installing `invoke` #4589 worked for
him, which means that this newly added paramiko feature works in
his case. That feature seems to not affect configs without `Match`,
so we could give this a try and install it by default.

Our conda package will be updated during the next release.

Fixes #4589
Fixes #4616
2020-09-24 17:45:06 +03:00
Alexanderandaandrusenko e928034fac add py support for ParamsDependency (#4456)
* add py support for ParamsDependency

* add py support by ast

* add support modify py params

Co-authored-by: aandrusenko <[email protected]>
2020-09-24 17:25:44 +03:00
Ruslan Kuprieiev 749fec381f dvc: bump to 1.7.5 2020-09-24 16:26:04 +03:00
Ruslan Kuprieiev 21de21fe37 gha: adjust the test workflow (#4615)
* gha: move lint check to tests workflow

* gha: tests: don't fail fast

* scripts: remove redundant check
2020-09-24 16:13:03 +03:00
Ruslan Kuprieiev 0756af5012 setup: remove wheel from install_requires (#4614)
Kudos to @skshetry for catching this one in https://github.com/iterative/dvc/pull/4606
2020-09-24 15:39:54 +03:00
Ruslan Kuprieiev 2457633990 gha: build packages (#4607) 2020-09-24 14:48:04 +03:00
David Roberts 46bc89319a install: Potential fix for (non-fatal?) setup errors on Ubuntu 20.04. (#4606)
Wheels fail to build on initial install. Install `wheel` to fix this.

Original error message:
Building wheel for pyyaml (setup.py) ... error
  ERROR: Command errored out with exit status 1:
   command: /home/david/Documents/job-hunting/dvc/.env/bin/python3 -u -c 'import sys, setuptools, tokenize; sys.argv[0] = '"'"'/tmp/pip-install-nhmea2xe/pyyaml/setup.py'"'"'; __file__='"'"'/tmp/pip-install-nhmea2xe/pyyaml/setup.py'"'"';f=getattr(tokenize, '"'"'open'"'"', open)(__file__);code=f.read().replace('"'"'\r\n'"'"', '"'"'\n'"'"');f.close();exec(compile(code, __file__, '"'"'exec'"'"'))' bdist_wheel -d /tmp/pip-wheel-v2ewd51w
       cwd: /tmp/pip-install-nhmea2xe/pyyaml/
  Complete output (6 lines):
  usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
     or: setup.py --help [cmd1 cmd2 ...]
     or: setup.py --help-commands
     or: setup.py cmd --help

  error: invalid command 'bdist_wheel'
  ----------------------------------------
  ERROR: Failed building wheel for pyyaml
  Running setup.py clean for pyyaml

Potential fix applied from:
	https://stackoverflow.com/questions/34819221/why-is-python-setup-py-saying-invalid-command-bdist-wheel-on-travis-ci

Fixes #4605.
2020-09-24 02:02:15 +03:00
Ruslan Kuprieiev 4a82974da4 run-cache: respect cache.shared config option (#4596)
Fixes #4595
2020-09-24 01:00:53 +03:00
Ruslan Kuprieiev 688da04d4a tests: cache test directories (#4601)
* tests: ignore more hdfs tests

* tests: remove unused mark

* gha: don't limit the number of test workers

* cache created test dirs
2020-09-23 23:22:27 +03:00
Paweł Redzyński e1b34442aa plot: accept any viable target in dvc repo (#4602) 2020-09-23 17:02:12 +03:00
Ruslan Kuprieiev 38dd1c9fe7 tests: allow failure for hdfs tests (#4600) 2020-09-23 00:53:32 +03:00
Paweł Redzyński b31bd1cb30 github actions tests: initial (#4469)
* github actions tests: initial

* build: tests: add codecov action
2020-09-22 23:05:06 +03:00
Paweł Redzyński 2b3c30cb0c metrics: accept any viable target in DvcRepo (#4590) 2020-09-22 22:53:55 +03:00
ebaffd728e Restyle Do not require account-level permissions for Azure tree (#4583)
* Do not require account-level permissions for Azure tree

* Only allow the existence check to pass without error if it failed due to authorization issues

* Update dvc/tree/azure.py

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

* Update dvc/tree/azure.py

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

* Restyled by isort

* Update azure.py

Co-authored-by: Matt Hollingsworth <[email protected]>
Co-authored-by: Matt Hollingsworth <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Restyled.io <[email protected]>
2020-09-22 00:07:56 +03:00
Ruslan Kuprieiev ece7d6ba67 dvc: bump to 1.7.4 2020-09-21 23:50:53 +03:00
Ruslan Kuprieiev cf5d461f4d api: enter state when trying to get a hash (#4584)
`get_hash` is an internal api, so conventions should be respected.

Fixes #4575
2020-09-21 23:37:51 +03:00
Ruslan Kuprieiev ff3b686058 dvc: bump to 1.7.3 2020-09-21 20:24:02 +03:00
Ruslan Kuprieiev 0b9cd4c93c gdrive: don't use hacks for empty files (#4588)
This bug is fixed in PyDrive2 1.6.2.
2020-09-21 20:23:19 +03:00
Robert LowryandRuslan Kuprieiev 15488ebefd scm: add config option to stage files automatically (#4543)
* scm: add "autostage" config option to stage files automatically

Fixes #4330

* Update tests/unit/scm/test_scm.py

* Update tests/unit/scm/test_scm.py

* scm_context, git: call reset_tracked_files from scm_context, remove output message

* Update dvc/repo/scm_context.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-09-21 11:59:25 +03:00
Peter Rowlands (변기호) 6a3c516b71 experiments: support committing to existing experiment branches (#4573)
* experiments: stash optional branch to extend

* experiments: support commit to existing experiment branch
2020-09-19 21:49:40 +03:00
Ruslan Kuprieiev de768375ed dvc: bump to 1.7.2 2020-09-15 12:14:49 +03:00
Ruslan Kuprieiev 2f88fa88c4 pydrive2: require six >= 1.13.0
https://github.com/iterative/PyDrive2/pull/56
2020-09-15 11:31:52 +03:00
01abf7c19c Fix error during external outputs checkout (#4570)
* Fix error during external outputs checkout

User in discord complained that external outputs' checkout
was failing. Looks like, during refactor of `HashInfo`,
this was forgotten, and as no tests existed before, we had
a regression from 1.6.5 onwards.

* fix tests

* Restyled by black (#4571)

Co-authored-by: Restyled.io <[email protected]>

Co-authored-by: restyled-io[bot] <32688539+restyled-io[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <[email protected]>
2020-09-15 11:07:09 +03:00
Ruslan Kuprieiev 4ebacf1e0a pydrive2: require >=1.6.1
https://github.com/iterative/PyDrive2/pull/56
2020-09-15 11:06:36 +03:00
karajan1001 5e69e29b2f check-ignore show sub-repo patterns (#4564)
* `check-ignore` show sub-repo patterns

fix #4555
1. Sub-repo pattern now would be considered in `check-ignore` command.
2. New outputs for sub-repo patterns in a `detail`
3. Related tests.

* message change
2020-09-15 06:59:41 +03:00
Ruslan Kuprieiev bc5611968f dvc: bump to 1.7.1 2020-09-14 12:16:14 +03:00
Ruslan Kuprieiev 8c19492ba6 tree: validate resolved remote:// sections (#4569)
* tree: validate resolved remote:// sections

* dvc: use checksum_jobs instead of hash_jobs

This is a regression introduced during one of the refactorings.
2020-09-14 12:15:38 +03:00
Ruslan Kuprieiev 61be19b9b5 Update version.py 2020-09-14 07:31:49 +03:00
Ruslan Kuprieiev 424396b068 tree: resolve deep remote:// urls (#4568)
Fixes #4556
2020-09-14 05:49:54 +03:00
karajan1001andRuslan Kuprieiev 8b16af965c Print dvc version info for debugging (#4536)
* Print dvc version info for debugging

fix #4095
1. seperate info from dvc version.
2. print dvc version info when some unexpected error occured.

* Psutil NoneType error

* Update tests/func/test_version.py

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

* Move `package` outside dvc/utils/pkg.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-09-13 04:08:20 +03:00
Ruslan Kuprieiev cf7e534e2d tree: repo: handle dirty state (#4554) 2020-09-11 17:25:33 +03:00
Saugat Pachhai 2931d30ca5 dvc: change dvcfile.dump to do synchronized dump by default (#4544)
The API of `Dvcfile.dump` has been changed to
`dump(stage, update_pipeline=True, update_lock=True)`,
which will make it to always dump by default to both files,
and provide flags to suppress one or the other.

Fixes #4235
2020-09-10 13:12:00 +05:45
Robert F. DickersonandRuslan Kuprieiev 7fa90e425d Allow config option for HTTP method for uploads (Artifactory) (#4553)
* allow for a config option for http method

* add tests

* fix tests with method

* Update dvc/tree/http.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-09-10 05:11:41 +03:00
Victor 54a48e3256 Feature/http ssl verify configuration (#4547)
* add test for http ssl_verify configuration

* add ssl_verify flag to HTTPTree
2020-09-09 19:27:43 +03:00
Ruslan Kuprieiev 1ca2dc19c2 tree: open: handle dirty state in RepoTree (#4548)
We used to use that logic in DvcTree back before we had proper trees.
Now we have a RepoTree, that combines git/workspace tree with the
DvcTree and dirty state clearly belongs to the git/workspace and not
the dvc itself, as dvc state only depends on dvc-files and lock-files.

Prerequisite for #4523
2020-09-09 18:44:11 +03:00
Alexander Schepanovski 67ddb7b41f Make repo lock reentrable and provide a context manager (#4416) 2020-09-08 17:40:34 +03:00
Ruslan Kuprieiev 61bf1194c3 tests: add gs etag
Kudos to @skshetry
2020-09-08 09:29:56 +03:00
Ruslan Kuprieiev ccdcb1f4c1 codecov: remove old token
Kudos to @skshetry
2020-09-08 09:24:38 +03:00
Ruslan Kuprieiev 226322d312 gdrive: add workaround for #4507
Temporary workaround. Proper solution will be introduced in https://github.com/iterative/PyDrive2/pull/48
2020-09-08 05:27:20 +03:00
Alexander Schepanovski e1f43965a2 Skip no data plots while rendering (#4541) 2020-09-07 23:25:43 +03:00
Ruslan Kuprieiev 18e80b25ac codecov: add token 2020-09-07 23:16:30 +03:00
Saugat Pachhai 131099750e Disable cprofile on exit of the contextmanager profile (#4542)
Does not change anything, it's good to disable it just after yield to not show other stats by accident.
2020-09-07 14:50:36 +05:45
Peter Rowlands (변기호) d1cc143ef2 diff: add state for files missing from both workspace and cache (#4530)
* diff: support missing cache status for workspace diff

* diff: add --hide-missing option to hide cache missing status

* update command tests

* update diff func tests

* only show missing cache status when diffing local workspace

* suppress all output if no changes and --hide-missing

* use hide_missing instead of show_missing

* use is_dvc
2020-09-07 04:44:04 +03:00
Ruslan Kuprieiev 93a298343f run: consolidate run-cache logic (#4535)
Existing lock files are just an edge case of run-cache.

Prerequisite for #4223
2020-09-06 17:46:25 +03:00
Ruslan Kuprieiev 636a019289 dvc: compute dir hash without external cache (#4528)
Fixes #4144
2020-09-05 16:31:34 +03:00
Victor 79e8e4edaf dvc: override replace in HTTPURLInfo to include extra_parts (#4517)
* dvc: override replace in HTTPURLInfo to include extra_parts

The objective is to passtrough any parameter configured in http remotes.

fixes #4508

* add unit test for #4508

* Use HTTPURLInfo directly instead of decorator
2020-09-03 15:47:18 +03:00
Peter Rowlands (변기호) 1f495d66dd diff: use RepoTree to compare directory contents (#4518)
* diff: use RepoTree to get granular results for dir outs

* update tests for dir file granularity

* diff: prefer repo_tree.get_hash when possible

* DvcTree: handle extra files in dirty dvc out dirs
2020-09-03 15:12:57 +03:00
Saugat Pachhai 4564e8be30 support subrepos for get/list/api/import/ls (#4465)
* support subrepos

This adds support for get/list/api/import/ls on a subrepo.

Also fixes #3180, by adding a granular url support for `dvc.api.get_url`
And, of course it fixes #3369.

* Add tests

* Fix tests, started to get lock_error

* Fix tests for windows

* try to fix mac builds

* Fix comments, readd removed comments

* Fix test

* address review suggestions:

* use `spy` instead of `wraps`
* remove dvc fixture in a test
* set `cache_types` on Git repo
2020-09-03 13:47:14 +03:00
d460d18942 version: improve link type detection fall back (#4479)
* version: improve link type detection fall back

If failed to determine cache type print link to related web page.

Fixes #2788

* Update dvc/command/version.py

* fix formatting

Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-09-03 12:03:56 +03:00
Ruslan Kuprieiev 4901765cd4 get/import: fix bug when working with mixed dirs (#4516)
Fixes #4510
2020-09-02 05:53:09 +03:00
Peter Rowlands (변기호) 24be96fce9 experiments: suppress repro --params option by default (#4515) 2020-09-02 10:17:01 +09:00
Ruslan Kuprieiev 981dd401fa cache: use get_file_hash instead of get_hash (#4514)
This is what we've been doing before. No need to try to pull it up from
the state db, as this is a clearly newly generated file that will get
deleted.
2020-09-02 03:37:17 +03:00
Ruslan Kuprieiev 9887de6a65 dvc: separate cache and tree hashes (#4511)
Related to #4144 , #3069 , #1676
2020-09-02 00:30:02 +03:00
Saugat Pachhai 8a270329ed lock: retry when trying to acquire lock (#4481)
Previously, it'd sleep for 5s. Now, it should retry 6 times
before timing out.

Also reduced the timeout to 3s.
2020-09-01 17:28:04 +03:00
Saugat Pachhai 747cb649f7 tree.repo: fix race condition when walking nested subrepos (#4505) 2020-09-01 07:11:21 +03:00
Ruslan Kuprieiev fc321683d8 dvc: use HashInfo to interact with cache (#4506) 2020-09-01 03:32:51 +03:00
Ruslan Kuprieiev 56500601bd deps/outs: get rid of self.info (#4502)
By itself `self.info` is quite confusing, as it is not clear what it is
about. Using `hash_info` makes much more sense and is required to
support alternative hash types.

Related to #4144, #3069, #1676
2020-08-31 22:46:18 +03:00
Peter Rowlands (변기호) 4f8432886d experiments: support column ordering & sorting in dvc exp show (#4503)
* experiments: preserve --include-... column ordering

* experiments: add --sort-by and --sort-order for exp show

* remove for-else
2020-08-31 23:17:58 +09:00
Ruslan Kuprieiev 54402f3de5 gitignore: ignore /cache instead of cache (#4501) 2020-08-31 05:53:44 +03:00
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
312 changed files with 17780 additions and 5223 deletions
+10 -1
View File
@@ -1 +1,10 @@
comment: false
coverage:
status:
project:
default:
target: 85
threshold: 10%
patch:
default:
threshold: 25%
+4
View File
@@ -1,10 +1,14 @@
[run]
branch = True
source = dvc
omit =
*__main__.py
[report]
exclude_lines =
if __name__ == .__main__.:
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
pass
raise NotImplementedError
# pragma: no cover
+46 -2
View File
@@ -1,5 +1,49 @@
# 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"
- "ui"
- 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! 🎉
-19
View File
@@ -1,19 +0,0 @@
name: Lint Python
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Install requirements
run: bash ./scripts/ci/install.sh
- name: Check Patch
run: ./scripts/ci/check_patch.sh
+131
View File
@@ -0,0 +1,131 @@
name: Build packages
on:
push:
branches:
- master
release:
types: [released]
jobs:
linux:
runs-on: ubuntu-16.04
steps:
- uses: actions/[email protected]
- name: Set up Python 3.7
uses: actions/[email protected]
with:
python-version: 3.7
- name: Set up Ruby 2.4
uses: actions/setup-ruby@v1
with:
ruby-version: 2.4
- name: Build
run: ./scripts/build_posix.sh
- name: Publish deb
if: github.event_name == 'release'
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: dvc_${{ github.event.release.tag_name }}_amd64.deb
asset_name: dvc_${{ github.event.release.tag_name }}_amd64.deb
asset_content_type: binary/octet-stream
- name: Publish rpm
if: github.event_name == 'release'
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm
asset_name: dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm
asset_content_type: binary/octet-stream
mac:
runs-on: macos-10.15
steps:
- uses: actions/[email protected]
- name: Set up Python 3.7
uses: actions/[email protected]
with:
python-version: 3.7
- name: Set up Ruby 2.4
uses: actions/setup-ruby@v1
with:
ruby-version: 2.4
- name: Build
run: ./scripts/build_posix.sh
- name: Publish
if: github.event_name == 'release'
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: dvc-${{ github.event.release.tag_name }}.pkg
asset_name: dvc-${{ github.event.release.tag_name }}.pkg
asset_content_type: binary/octet-stream
windows:
runs-on: windows-2019
steps:
- uses: actions/[email protected]
- name: Set up Python 3.7
uses: actions/[email protected]
with:
python-version: 3.7
- name: Build
run: scripts\build_windows.cmd
shell: cmd
- name: Publish
if: github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/[email protected]
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: dvc-${{ github.event.release.tag_name }}.exe
asset_name: dvc-${{ github.event.release.tag_name }}.exe
asset_content_type: binary/octet-stream
python:
runs-on: ubuntu-16.04
steps:
- uses: actions/[email protected]
- name: Set up Python 3.7
uses: actions/[email protected]
with:
python-version: 3.7
- name: Install
run: pip install ".[all,tests]"
- name: Build packages
run: ./scripts/build_package.sh
- name: Publish packages to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@master
with:
user: __token__
password: ${{ secrets.PYPI_TOKEN }}
snap:
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- name: Build the package
uses: snapcore/action-build@v1
id: snapcraft
- name: Test the package
run: |
sudo snap install --dangerous --classic ${{ steps.snapcraft.outputs.snap }}
dvc version
sudo snap remove dvc
- name: Publish to the edge channel
uses: snapcore/action-publish@v1
if: github.event_name == 'push'
with:
store_login: ${{ secrets.STORE_LOGIN }}
snap: ${{ steps.snapcraft.outputs.snap }}
release: edge
- name: Publish to the stable channel
uses: snapcore/action-publish@v1
if: github.event_name == 'release'
with:
store_login: ${{ secrets.STORE_LOGIN }}
snap: ${{ steps.snapcraft.outputs.snap }}
release: stable
+86
View File
@@ -0,0 +1,86 @@
name: Tests
on:
pull_request:
branches:
- master
push:
branches:
- master
env:
DVC_TEST: "true"
HOMEBREW_NO_AUTO_UPDATE: 1
SHELL: /bin/bash
GDRIVE_CREDENTIALS_DATA: ${{ secrets.GDRIVE_CREDENTIALS_DATA }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
GS_CREDS_JSON: ${{ secrets.GS_CREDS_JSON }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- name: Set up Python 3.8
uses: actions/[email protected]
with:
python-version: 3.8
- name: Install requirements
run: pip install ".[all,tests]" Pygments collective.checkdocs pre-commit
- name: Check README
run: python setup.py checkdocs
- name: Check formatting
run: pre-commit run --all-files
tests:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-18.04, windows-2019, macos-10.15]
pyv: ["3.6", "3.7", "3.8", "3.9"]
include:
- os: "macos-11.0"
pyv: "3.9"
exclude:
- os: macos-10.15
pyv: "3.9"
steps:
- uses: actions/[email protected]
- name: Set up Python
uses: actions/[email protected]
with:
python-version: ${{ matrix.pyv }}
- name: install
run: |
pip install wheel
pip install -e ".[all,tests]"
- name: setup git
run: |
git config --global user.email "[email protected]"
git config --global user.name "DVC Tester"
- name: setup gs creds
shell: bash
if: env.GS_CREDS_JSON != ''
run: |
mkdir -p scripts/ci
echo "$GS_CREDS_JSON" > scripts/ci/gcp-creds.json
- name: run tests
run: python -m tests --all --cov-report=xml --cov-report=term --tap-combined
- name: upload coverage report
uses: codecov/[email protected]
with:
file: ./coverage.xml
fail_ci_if_error: false
- name: Post TAP
# Running custom fork with an option, that does not fail CI on error
uses: skshetry/flaky-service/packages/action@master
if: always() && github.ref == 'refs/heads/master'
with:
file-path: ${{github.workspace}}/testresults.tap
repo-token: ${{ secrets.GITHUB_TOKEN }}
os: ${{runner.os}}
matrix: ${{toJson(matrix)}}
repo-description: ${{github.event.repository.description}}
fail_ci_if_error: false
+1 -2
View File
@@ -1,12 +1,11 @@
neatlynx.conf
neatlynx/__pycache__
.idea
cache
/cache
*.pyc
.env/
.env2.7/
cache
.dvc.conf.lock
.DS_Store
build/
+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
@@ -265,7 +265,7 @@ contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=pytest.lazy_fixture,logging.TRACE,logger.trace,sys.getwindowsversion
generated-members=hdfs.*,pytest.lazy_fixture,logging.TRACE,logger.trace,sys.getwindowsversion
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
-20
View File
@@ -1,20 +0,0 @@
---
restylers:
- name: black
image: restyled/restyler-black:v19.10b0
command:
- black
arguments: []
include:
- "**/*.py"
interpreters:
- python
- name: isort
image: restyled/restyler-isort:v4.3.21
command:
- isort
arguments: []
include:
- "**/*.py"
interpreters:
- python
-162
View File
@@ -1,162 +0,0 @@
cache:
directories:
- $HOME/.cache/pip
- $HOME/.pyenv
dist: xenial
env:
global:
- HOMEBREW_NO_AUTO_UPDATE=1
- PYENV_ROOT="$HOME/.pyenv"
- PATH="$PYENV_ROOT/bin:$PATH"
- PIP_CACHE_DIR="$HOME/.cache/pip" # unify pip cache location for all platforms
stages:
- check
- test
- build
# Travis doesn't support testing python on Mac yet, so we need
# to workaround it by installing it directly with brew.
#
# Note that we specify language as ruby-2.4.3 to help us satisfy
# fpm dependencies, as travis runs el capitan with an old ruby
# version, that causes fpm to crash.
#
# Each job will use the previous job's `stage` tag if not specified.
jobs:
include:
# patch formatting checks
- name: "Check patch formatting"
stage: check
os: linux
language: python
python: 3.7
before_install: bash ./scripts/ci/install.sh
install:
script: ./scripts/ci/check_patch.sh
# test jobs
- name: "3.7 on Windows"
stage: test
os: windows # Windows 10.0.17134 N/A Build 17134
language: shell # 'language: python' is an error on Travis CI Windows
env:
- PYTHON_VERSION=3.7.5
- PATH=/c/Python37:/c/Python37/Scripts:$PATH
- name: "3.8 on Windows"
os: windows # Windows 10.0.17134 N/A Build 17134
language: shell # 'language: python' is an error on Travis CI Windows
env:
- PYTHON_VERSION=3.8.0
- PATH=/c/Python38:/c/Python38/Scripts:$PATH
- name: "3.6 on Linux"
language: python
python: 3.6
- name: "3.7 on Linux"
language: python
python: 3.7
- name: "3.8 on Linux"
language: python
python: 3.8
- name: "3.7 on Mac"
os: osx
osx_image: xcode11.3
# build jobs
- name: "Mac pkg"
stage: build
os: osx
osx_image: xcode9.4
language: ruby
rvm: 2.4.3
install:
script: ./scripts/build_posix.sh
- name: "Windows pkg"
os: windows
language: shell
install:
script: ./scripts/build_windows.cmd
env:
- PYTHON_VERSION=3.7.5
- PATH=/c/Python37:/c/Python37/Scripts:$PATH
- name: "Linux pkgs"
language: python
python: 3.7
before_install:
install:
script: ./scripts/build_posix.sh
- name: "PyPI pkgs"
language: python
python: 3.8
script: ./scripts/build_package.sh
- name: Snapcraft snap
language: python
python: 3.8
addons:
snaps:
- name: snapcraft
channel: stable
confinement: classic
- name: review-tools
channel: stable
- name: lxd
channel: stable
env:
- SNAPCRAFT_IMAGE_INFO: |
'{"build_url": "$TRAVIS_JOB_WEB_URL"}'
- SNAPCRAFT_BUILD_ENVIRONMENT: lxd
- SNAPCRAFT_BUILD_INFO: 1 # https://snapcraft.io/blog/introducing-developer-notifications-for-snap-security-updates
install:
- sudo usermod --append --groups lxd $USER
- sudo /snap/bin/lxd.migrate -yes
- sudo /snap/bin/lxd waitready
- sudo /snap/bin/lxd init --auto
script:
- ./scripts/build_snap.sh
after_failure:
- sudo journalctl -u snapd
before_install:
- bash ./scripts/ci/before_install.sh
- source env.sh
- bash ./scripts/ci/check_python.sh
install:
- bash ./scripts/ci/install.sh
script:
- bash ./scripts/ci/script.sh
deploy:
- provider: releases
api_key:
secure: nyo17gF4Xl6DFL/KVkMewC805Cv+eK99L7SiYCU+WUAYDIzzFL3YJm7fm8bznPeor7DmpO7uSLSveMnJETBtdurW6JWhCTUss4yD66yFzpk6Z5cvHf/1UA5pdMHkkMM54fRaTRt6L2Yal7RPwGEQTBECLXW1Xmf78Y6+0l8lTvXQ9jXOQSRO/63VDKI5Zww2+XytVicUFlr0hLMGKUuaCjjXcQlAPzBgQz3/5Iz6EtzeTf2o+gTPjocbRbSFPikfiBXUn8XlJ8jBj7i114XJRJffw59KxYGDzxp+enfLBULGNoW0Np2hZqkKkPUIdB08C8XVbRXlFYX8cEYBczjqhv8A05p5F5CnltnqQ0KazcoUP2awPVqn68dWNRF+kqrnMBus8euYKIZ4dy/6bVEKJRa84ESJXZVDcwXTAXRqyovSxOlXqg9ZgAImMKBfKn/fpDX2xIvQPaXcnJxKbmcePfdbC1d0HYJ5jkbNaxKE/T15VvhzIho7mJ2WAv1r8QouQUuzqtpzIGbRn/KXHRUzCtyP0k4+eMKMLFOPzg848WsDarP3v8VYiSy4nFY7ZP7DRd3KttvW5bgVbSeO4jPmTgS6TnnpRwual9BJXSG7bjbfpIMii3bNZ7jWL4zpKT9Jfm8w9RGKLcX9HqkZ3+y60+LtYdhEfBvqsIlWQoIJBXg=
file_glob: true
file:
- dvc*.rpm
- dvc*.deb
- dvc*.pkg
- dvc*.exe
skip_cleanup: true
on:
condition: "$(./scripts/ci/deploy_condition.sh dvc*.rpm dvc*.deb dvc*.pkg dvc*.exe)"
tags: true
repo: iterative/dvc
stage: build
- provider: pypi
user:
secure: "IOzNfgPoG3ICg3+s4AuZCCnawgd/KqMzeiAB9R0B9Xb6ProPQJp9O3GBZrtg5OJMHdWQQtwULeOh/LWk4sFdXMcX1iNEgm5Tb7DwO7TDtltW14jUgN9AqfY2twj4UIsW/ON8w0P8v36ZoQq1kO6Pfgjijdm3w+9h6rpps8rJ6jXYpVZVO+SSzzijWddanMkEz4GgctZ2r7U5MWlmm1UogF60kEZIZCQykz0yJnhTpP9nMFDKw6Maki/mcfVwTQ/v3RqjzttHei/aaEMGQXYr3TOX8NPYJXerBpjShDZqcgcCfqPzhi7uCwVYaPvRsMMYH5jgtMz/SO3J6ToeGKT0f1F1ihRyCdusg325TMK7Vv8yHJzL4ngeUbb/oqxWtxhLY7f77Lkx6hBwe6ROpTqiQHIfZad5qyIHihKcDcIHo4FwLHVzxR4y+Ds2js5dXwBkUBaEAx0Dmc6ZdgXqxd815GDwv+VVpzXJhtO6qXmKE36F/Oe6eCNG3dk5MdOK4C2DrM9M8MXllqeQtX6JrM2stWlBalAc9Jn4b0xxAoghX3x3G3hNsLc9Uist1VinY2+IcPozusqj66c+Vbi5YngWSbICOjlIcq2GaOlBi3xioGts396442ztf1eLjMVuvykCI3b4Yz/vJYZOZjUvG/6nLfaFQTJLSXkTvI4do3foeY4="
password:
secure: "ts/3FSPL+w4euXryip9wCJWUqUwuJ2CqHi0nEpYVM10KL0cp9aMkQXN/7C8ih+j/JZ25kaO+tis1nx0JERVnKrg/1F/fJhxHTpakCP4OSIlhKRXDt7yUUHcKKt5QdM2XCKhApptP6RTpWL1vlc36CMrhdvTcMYp7hlDOiWjdhhim24FGQzabL42Ko59ttyyCROqgcnjHqj+WlhJl7iYO+lv2PLOtslpkIYY5EMjgBLuGyOvxTqZsAXtworsASdB2CrsTE6iMRcrM1afgkyBlUqDezoiv9TwfZrZ85oRoAxPB7su4QWK7lEP0wGQV/SDz/RL7c+cNfRaauxUKVcC8I2LfaEpudv8vi205+v/08wzPbS4xSOnf89irUJnyGxUy+bYLoXPPVPMyzcQRAUnrQl+ENCmN62HU+/2pkIyiUJvt0wYYP0vp+3pIbXbYQGmAIMq7nZJd7K3rikfnQIGKI03xZsFe3PEdjzhDI9Ej/tRUbJx4pgg084QuYYca6MSYn/zJ5pqIG4A7ngCSxoxoWZT0duo8PrUWQeT17L73Q86HeEv3OzZhWfEF2fNDN3Qbsu4CllSZnbE7DXgAuysTwTYHS/na6lp7wejOqdRUXrHbidVla9Mwxyc9JdWNWzUDxALvNxAdNbV6l85mO8izy8wjQF6M1O7idqTfjQeR4pc="
skip_existing: true
file_glob: true
file:
- dist/dvc*.whl
- dist/dvc*.tar.gz
skip_cleanup: true
on:
condition: "$(./scripts/ci/deploy_condition.sh dist/dvc*.whl dist/dvc*.tar.gz)"
tags: true
repo: iterative/dvc
stage: build
- provider: script
skip_cleanup: true
script: "(echo $SNAP_TOKEN | snapcraft login --with -) && timeout 600 snapcraft push dvc_*.snap --release $SNAP_CHANNEL || echo timed out"
on:
all_branches: true
condition: "$(./scripts/ci/deploy_condition.sh dvc_*.snap) && ($TRAVIS_BRANCH = master || -n $TRAVIS_TAG) && -n $SNAP_CHANNEL"
repo: iterative/dvc
stage: build
+3 -3
View File
@@ -262,9 +262,9 @@ Iterative, *DVC: Data Version Control - Git for Data & Models* (2020)
:target: https://travis-ci.com/iterative/dvc/branches
:alt: Release
.. |CI| image:: https://img.shields.io/travis/com/iterative/dvc/master?label=dev&logo=travis
:target: https://travis-ci.com/iterative/dvc/builds
:alt: Travis dev branch
.. |CI| image:: https://github.com/iterative/dvc/workflows/Tests/badge.svg?branch=master
:target: https://github.com/iterative/dvc/actions
:alt: GHA Tests
.. |Maintainability| image:: https://codeclimate.com/github/iterative/dvc/badges/gpa.svg
:target: https://codeclimate.com/github/iterative/dvc
+52 -15
View File
@@ -2,35 +2,40 @@ import os
from contextlib import _GeneratorContextManager as GCM
from contextlib import contextmanager
from dvc.exceptions import DvcException, NotDvcRepoError
from funcy import reraise
from dvc.exceptions import (
NotDvcRepoError,
OutputNotFoundError,
PathMissingError,
)
from dvc.external_repo import external_repo
from dvc.path_info import PathInfo
from dvc.repo import Repo
class UrlNotDvcRepoError(DvcException):
"""Thrown if the given URL is not a DVC repository."""
def __init__(self, url):
super().__init__(f"'{url}' is not a DVC repository.")
def get_url(path, repo=None, rev=None, remote=None):
"""
Returns the URL to the storage location of a data file or directory tracked
in a DVC repo. For Git repos, HEAD is used unless a rev argument is
supplied. The default remote is tried unless a remote argument is supplied.
Raises UrlNotDvcRepoError if repo is not a DVC project.
Raises OutputNotFoundError if the file is not tracked by DVC.
NOTE: This function does not check for the actual existence of the file or
directory in the remote storage.
"""
with _make_repo(repo, rev=rev) as _repo:
if not isinstance(_repo, Repo):
raise UrlNotDvcRepoError(_repo.url) # pylint: disable=no-member
out = _repo.find_out_by_relpath(path)
remote_obj = _repo.cloud.get_remote(remote)
return str(remote_obj.tree.hash_to_path_info(out.checksum))
path_info = PathInfo(_repo.root_dir) / path
with reraise(FileNotFoundError, PathMissingError(path, repo)):
metadata = _repo.repo_tree.metadata(path_info)
if not metadata.is_dvc:
raise OutputNotFoundError(path, repo)
cloud = metadata.repo.cloud
hash_info = _repo.repo_tree.get_hash(path_info)
return cloud.get_url_for(remote, checksum=hash_info.value)
def open( # noqa, pylint: disable=redefined-builtin
@@ -97,9 +102,41 @@ def _make_repo(repo_url=None, rev=None):
repo_url = repo_url or os.getcwd()
if rev is None and os.path.exists(repo_url):
try:
yield Repo(repo_url)
yield Repo(repo_url, subrepos=True)
return
except NotDvcRepoError:
pass # fallthrough to external_repo
with external_repo(url=repo_url, rev=rev) as repo:
yield repo
def make_checkpoint():
"""
Signal DVC to create a checkpoint experiment.
If the current process is being run from DVC, this function will block
until DVC has finished creating the checkpoint. Otherwise, this function
will return immediately.
"""
import builtins
from time import sleep
from dvc.env import DVC_CHECKPOINT, DVC_ROOT
from dvc.stage.run import CHECKPOINT_SIGNAL_FILE
if os.getenv(DVC_CHECKPOINT) is None:
return
root_dir = os.getenv(DVC_ROOT, Repo.find_root())
signal_file = os.path.join(
root_dir, Repo.DVC_DIR, "tmp", CHECKPOINT_SIGNAL_FILE
)
with builtins.open(signal_file, "w") as fobj:
# NOTE: force flushing/writing empty file to disk, otherwise when
# run in certain contexts (pytest) file may not actually be written
fobj.write("")
fobj.flush()
os.fsync(fobj.fileno())
while os.path.exists(signal_file):
sleep(1)
+46 -41
View File
@@ -1,45 +1,31 @@
"""Manages cache of a DVC repo."""
from collections import defaultdict
from funcy import cached_property
from ..scheme import Schemes
def _make_remote_property(name):
"""
The config file is stored in a way that allows you to have a
cache for each remote.
def get_cloud_cache(tree):
from .base import CloudCache
from .local import LocalCache
from .ssh import SSHCache
This is needed when specifying external outputs
(as they require you to have an external cache location).
if tree.scheme == Schemes.LOCAL:
return LocalCache(tree)
Imagine a config file like the following:
if tree.scheme == Schemes.SSH:
return SSHCache(tree)
['remote "dvc-storage"']
url = ssh://localhost/tmp
ask_password = true
return CloudCache(tree)
[cache]
ssh = dvc-storage
This method creates a cached property, containing cache named `name`:
def _get_cache(repo, settings):
from ..tree import get_cloud_tree
self.config == {'ssh': 'dvc-storage'}
self.ssh # a RemoteSSH instance
"""
if not settings:
return None
def getter(self):
from ..tree import get_cloud_tree
from .base import CloudCache
remote = self.config.get(name)
if not remote:
return None
tree = get_cloud_tree(self.repo, name=remote)
return CloudCache(tree)
getter.__name__ = name
return cached_property(getter)
tree = get_cloud_tree(repo, **settings)
return get_cloud_cache(tree)
class Cache:
@@ -50,29 +36,48 @@ class Cache:
"""
CACHE_DIR = "cache"
CLOUD_SCHEMES = [
Schemes.S3,
Schemes.GS,
Schemes.SSH,
Schemes.HDFS,
Schemes.WEBHDFS,
]
def __init__(self, repo):
from ..tree import get_cloud_tree
from .local import LocalCache
self.repo = repo
self.config = config = repo.config["cache"]
self._cache = {}
local = config.get("local")
if local:
settings = {"name": local}
elif "dir" not in config:
settings = None
else:
settings = {**config, "url": config["dir"]}
from ..config import LOCAL_COMMON
tree = get_cloud_tree(repo, **settings)
self.local = LocalCache(tree)
settings = {"url": config["dir"]}
for opt in LOCAL_COMMON.keys():
if opt in config:
settings[str(opt)] = config.get(opt)
s3 = _make_remote_property("s3")
gs = _make_remote_property("gs")
ssh = _make_remote_property("ssh")
hdfs = _make_remote_property("hdfs")
azure = _make_remote_property("azure")
self._cache[Schemes.LOCAL] = _get_cache(repo, settings)
for scheme in self.CLOUD_SCHEMES:
remote = self.config.get(scheme)
settings = {"name": remote} if remote else None
self._cache[scheme] = _get_cache(repo, settings)
def __getattr__(self, name):
try:
return self._cache[name]
except KeyError as exc:
raise AttributeError from exc
def by_scheme(self):
yield from self._cache.items()
class NamedCacheItem:
+509 -126
View File
@@ -1,32 +1,30 @@
import itertools
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from copy import copy
from typing import Optional
from funcy import decorator
from shortuuid import uuid
import dvc.prompt as prompt
from dvc.exceptions import CheckoutError, ConfirmRemoveError, DvcException
from dvc.path_info import WindowsPathInfo
from dvc.dir_info import DirInfo
from dvc.exceptions import (
CacheLinkError,
CheckoutError,
ConfirmRemoveError,
DvcException,
)
from dvc.progress import Tqdm
from dvc.remote.slow_link_detection import slow_link_guard
from dvc.remote.slow_link_detection import ( # type: ignore[attr-defined]
slow_link_guard,
)
from ..tree.base import RemoteActionNotImplemented
logger = logging.getLogger(__name__)
STATUS_OK = 1
STATUS_MISSING = 2
STATUS_NEW = 3
STATUS_DELETED = 4
STATUS_MAP = {
# (local_exists, remote_exists)
(True, True): STATUS_OK,
(False, False): STATUS_MISSING,
(True, False): STATUS_NEW,
(False, True): STATUS_DELETED,
}
class DirCacheError(DvcException):
def __init__(self, hash_):
@@ -35,11 +33,18 @@ class DirCacheError(DvcException):
)
@decorator
def use_state(call):
tree = call._args[0].tree # pylint: disable=protected-access
with tree.state:
return call()
class CloudCache:
"""Cloud cache class."""
DEFAULT_CACHE_TYPES = ["copy"]
CACHE_MODE = None
CACHE_MODE: Optional[int] = None
def __init__(self, tree):
self.tree = tree
@@ -51,47 +56,38 @@ class CloudCache:
self.cache_type_confirmed = False
self._dir_info = {}
def get_dir_cache(self, hash_):
assert hash_
def get_dir_cache(self, hash_info):
assert hash_info
dir_info = self._dir_info.get(hash_)
dir_info = self._dir_info.get(hash_info.value)
if dir_info:
return dir_info
try:
dir_info = self.load_dir_cache(hash_)
dir_info = self.load_dir_cache(hash_info)
except DirCacheError:
dir_info = []
dir_info = DirInfo()
self._dir_info[hash_] = dir_info
self._dir_info[hash_info.value] = dir_info
return dir_info
def load_dir_cache(self, hash_):
path_info = self.tree.hash_to_path_info(hash_)
def load_dir_cache(self, hash_info):
path_info = self.tree.hash_to_path_info(hash_info.value)
try:
with self.tree.open(path_info, "r") as fobj:
d = json.load(fobj)
except (ValueError, FileNotFoundError) as exc:
raise DirCacheError(hash_) from exc
raise DirCacheError(hash_info) from exc
if not isinstance(d, list):
logger.error(
"dir cache file format error '%s' [skipping the file]",
path_info,
)
return []
d = []
if self.tree.PATH_CLS == WindowsPathInfo:
# only need to convert it for Windows
for info in d:
# NOTE: here is a BUG, see comment to .as_posix() below
relpath = info[self.tree.PARAM_RELPATH]
info[self.tree.PARAM_RELPATH] = relpath.replace(
"/", self.tree.PATH_CLS.sep
)
return d
return DirInfo.from_list(d)
def changed(self, path_info, hash_info):
"""Checks if data has changed.
@@ -118,20 +114,21 @@ class CloudCache:
logger.debug("'%s' doesn't exist.", path_info)
return True
hash_ = hash_info.get(self.tree.PARAM_CHECKSUM)
if hash_ is None:
if not hash_info:
logger.debug("hash value for '%s' is missing.", path_info)
return True
if self.changed_cache(hash_):
logger.debug("cache for '%s'('%s') has changed.", path_info, hash_)
if self.changed_cache(hash_info):
logger.debug(
"cache for '%s'('%s') has changed.", path_info, hash_info
)
return True
actual = self.tree.get_hash(path_info)
if hash_ != actual:
if hash_info != actual:
logger.debug(
"hash value '%s' for '%s' has changed (actual '%s').",
hash_,
hash_info,
actual,
path_info,
)
@@ -176,7 +173,7 @@ class CloudCache:
)
del link_types[0]
raise DvcException("no possible cache types left to try out.")
raise CacheLinkError([to_info])
def _do_link(self, from_info, to_info, link_method):
if self.tree.exists(to_info):
@@ -188,12 +185,12 @@ class CloudCache:
"Created '%s': %s -> %s", self.cache_types[0], from_info, to_info,
)
def _save_file(self, path_info, tree, hash_, save_link=True, **kwargs):
assert hash_
def _save_file(self, path_info, tree, hash_info, save_link=True, **kwargs):
assert hash_info
cache_info = self.tree.hash_to_path_info(hash_)
cache_info = self.tree.hash_to_path_info(hash_info.value)
if tree == self.tree:
if self.changed_cache(hash_):
if self.changed_cache(hash_info):
self.tree.move(path_info, cache_info, mode=self.CACHE_MODE)
self.link(cache_info, path_info)
elif self.tree.iscopy(path_info) and self._cache_is_copy(
@@ -210,9 +207,9 @@ class CloudCache:
# we need to update path and cache, since in case of reflink,
# or copy cache type moving original file results in updates on
# next executed command, which causes md5 recalculation
self.tree.state.save(path_info, hash_)
self.tree.state.save(path_info, hash_info)
else:
if self.changed_cache(hash_):
if self.changed_cache(hash_info):
with tree.open(path_info, mode="rb") as fobj:
# if tree has fetch enabled, DVC out will be fetched on
# open and we do not need to read/copy any data
@@ -224,8 +221,7 @@ class CloudCache:
if callback:
callback(1)
self.tree.state.save(cache_info, hash_)
return {self.tree.PARAM_CHECKSUM: hash_}
self.tree.state.save(cache_info, hash_info)
def _cache_is_copy(self, path_info):
"""Checks whether cache uses copies."""
@@ -249,13 +245,55 @@ class CloudCache:
self.cache_type_confirmed = True
return self.cache_types[0] == "copy"
def _save_dir(self, path_info, tree, hash_, save_link=True, **kwargs):
dir_info = self.get_dir_cache(hash_)
for entry in Tqdm(
dir_info, desc="Saving " + path_info.name, unit="file"
def _get_dir_info_hash(self, dir_info):
import tempfile
from dvc.path_info import PathInfo
from dvc.utils import tmp_fname
tmp = tempfile.NamedTemporaryFile(delete=False).name
with open(tmp, "w+") as fobj:
json.dump(dir_info.to_list(), fobj, sort_keys=True)
from_info = PathInfo(tmp)
to_info = self.tree.path_info / tmp_fname("")
self.tree.upload(from_info, to_info, no_progress_bar=True)
hash_info = self.tree.get_file_hash(to_info)
hash_info.value += self.tree.CHECKSUM_DIR_SUFFIX
hash_info.dir_info = dir_info
hash_info.nfiles = dir_info.nfiles
return hash_info, to_info
@use_state
def save_dir_info(self, dir_info, hash_info=None):
if (
hash_info
and hash_info.name == self.tree.PARAM_CHECKSUM
and not self.changed_cache_file(hash_info)
):
return hash_info
hi, tmp_info = self._get_dir_info_hash(dir_info)
new_info = self.tree.hash_to_path_info(hi.value)
if self.changed_cache_file(hi):
self.tree.makedirs(new_info.parent)
self.tree.move(tmp_info, new_info, mode=self.CACHE_MODE)
self.tree.state.save(new_info, hi)
return hi
def _save_dir(self, path_info, tree, hash_info, save_link=True, **kwargs):
if not hash_info.dir_info:
hash_info.dir_info = tree.cache.get_dir_cache(hash_info)
hi = self.save_dir_info(hash_info.dir_info, hash_info)
for entry_info, entry_hash in Tqdm(
hi.dir_info.items(path_info),
desc="Saving " + path_info.name,
unit="file",
):
entry_info = path_info / entry[self.tree.PARAM_RELPATH]
entry_hash = entry[self.tree.PARAM_CHECKSUM]
self._save_file(
entry_info, tree, entry_hash, save_link=False, **kwargs
)
@@ -263,12 +301,12 @@ class CloudCache:
if save_link:
self.tree.state.save_link(path_info)
if self.tree.exists(path_info):
self.tree.state.save(path_info, hash_)
self.tree.state.save(path_info, hi)
cache_info = self.tree.hash_to_path_info(hash_)
self.tree.state.save(cache_info, hash_)
return {self.tree.PARAM_CHECKSUM: hash_}
cache_info = self.tree.hash_to_path_info(hi.value)
self.tree.state.save(cache_info, hi)
@use_state
def save(self, path_info, tree, hash_info, save_link=True, **kwargs):
if path_info.scheme != self.tree.scheme:
raise RemoteActionNotImplemented(
@@ -276,25 +314,23 @@ 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)
self._save(path_info, tree, hash_info, save_link, **kwargs)
def _save(self, path_info, tree, hash_, save_link=True, **kwargs):
to_info = self.tree.hash_to_path_info(hash_)
def _save(self, path_info, tree, hash_info, save_link=True, **kwargs):
to_info = self.tree.hash_to_path_info(hash_info.value)
logger.debug("Saving '%s' to '%s'.", path_info, to_info)
if tree.isdir(path_info):
return self._save_dir(path_info, tree, hash_, save_link, **kwargs)
return self._save_file(path_info, tree, hash_, save_link, **kwargs)
self._save_dir(path_info, tree, hash_info, save_link, **kwargs)
else:
self._save_file(path_info, tree, hash_info, save_link, **kwargs)
# Override to return path as a string instead of PathInfo for clouds
# which support string paths (see local)
def hash_to_path(self, hash_):
return self.tree.hash_to_path_info(hash_)
def changed_cache_file(self, hash_):
def changed_cache_file(self, hash_info):
"""Compare the given hash with the (corresponding) actual one.
- Use `State` as a cache for computed hashes
@@ -307,7 +343,7 @@ class CloudCache:
- Remove the file from cache if it doesn't match the actual hash
"""
# Prefer string path over PathInfo when possible due to performance
cache_info = self.hash_to_path(hash_)
cache_info = self.hash_to_path(hash_info.value)
if self.tree.is_protected(cache_info):
logger.debug(
"Assuming '%s' is unchanged since it is read-only", cache_info
@@ -317,13 +353,16 @@ class CloudCache:
actual = self.tree.get_hash(cache_info)
logger.debug(
"cache '%s' expected '%s' actual '%s'", cache_info, hash_, actual,
"cache '%s' expected '%s' actual '%s'",
cache_info,
hash_info,
actual,
)
if not hash_ or not actual:
if not hash_info or not actual:
return True
if actual.split(".")[0] == hash_.split(".")[0]:
if actual.value.split(".")[0] == hash_info.value.split(".")[0]:
# making cache file read-only so we don't need to check it
# next time
self.tree.protect(cache_info)
@@ -335,15 +374,13 @@ class CloudCache:
return True
def _changed_dir_cache(self, hash_, path_info=None, filter_info=None):
if self.changed_cache_file(hash_):
def _changed_dir_cache(self, hash_info, path_info=None, filter_info=None):
if self.changed_cache_file(hash_info):
return True
for entry in self.get_dir_cache(hash_):
entry_hash = entry[self.tree.PARAM_CHECKSUM]
dir_info = self.get_dir_cache(hash_info)
for entry_info, entry_hash in dir_info.items(path_info):
if path_info and filter_info:
entry_info = path_info / entry[self.tree.PARAM_RELPATH]
if not entry_info.isin_or_eq(filter_info):
continue
@@ -352,12 +389,12 @@ class CloudCache:
return False
def changed_cache(self, hash_, path_info=None, filter_info=None):
if self.tree.is_dir_hash(hash_):
def changed_cache(self, hash_info, path_info=None, filter_info=None):
if hash_info.isdir:
return self._changed_dir_cache(
hash_, path_info=path_info, filter_info=filter_info
hash_info, path_info=path_info, filter_info=filter_info
)
return self.changed_cache_file(hash_)
return self.changed_cache_file(hash_info)
def already_cached(self, path_info):
current = self.tree.get_hash(path_info)
@@ -383,11 +420,11 @@ class CloudCache:
self.tree.remove(path_info)
def _checkout_file(
self, path_info, hash_, force, progress_callback=None, relink=False
self, path_info, hash_info, force, progress_callback=None, relink=False
):
"""The file is changed we need to checkout a new copy"""
added, modified = True, False
cache_info = self.tree.hash_to_path_info(hash_)
cache_info = self.tree.hash_to_path_info(hash_info.value)
if self.tree.exists(path_info):
logger.debug("data '%s' will be replaced.", path_info)
self.safe_remove(path_info, force=force)
@@ -395,7 +432,7 @@ class CloudCache:
self.link(cache_info, path_info)
self.tree.state.save_link(path_info)
self.tree.state.save(path_info, hash_)
self.tree.state.save(path_info, hash_info)
if progress_callback:
progress_callback(str(path_info))
@@ -404,7 +441,7 @@ class CloudCache:
def _checkout_dir(
self,
path_info,
hash_,
hash_info,
force,
progress_callback=None,
relink=False,
@@ -417,25 +454,23 @@ class CloudCache:
added = True
self.tree.makedirs(path_info)
dir_info = self.get_dir_cache(hash_)
dir_info = self.get_dir_cache(hash_info)
logger.debug("Linking directory '%s'.", path_info)
for entry in dir_info:
relative_path = entry[self.tree.PARAM_RELPATH]
entry_hash = entry[self.tree.PARAM_CHECKSUM]
entry_cache_info = self.tree.hash_to_path_info(entry_hash)
entry_info = path_info / relative_path
for entry_info, entry_hash_info in dir_info.items(path_info):
entry_cache_info = self.tree.hash_to_path_info(
entry_hash_info.value
)
if filter_info and not entry_info.isin_or_eq(filter_info):
continue
entry_hash_info = {self.tree.PARAM_CHECKSUM: entry_hash}
if relink or self.changed(entry_info, entry_hash_info):
modified = True
self.safe_remove(entry_info, force=force)
self.link(entry_cache_info, entry_info)
self.tree.state.save(entry_info, entry_hash)
self.tree.state.save(entry_info, entry_hash_info)
if progress_callback:
progress_callback(str(entry_info))
@@ -445,7 +480,7 @@ class CloudCache:
)
self.tree.state.save_link(path_info)
self.tree.state.save(path_info, hash_)
self.tree.state.save(path_info, hash_info)
# relink is not modified, assume it as nochange
return added, not added and modified and not relink
@@ -453,15 +488,14 @@ class CloudCache:
def _remove_redundant_files(self, path_info, dir_info, force):
existing_files = set(self.tree.walk_files(path_info))
needed_files = {
path_info / entry[self.tree.PARAM_RELPATH] for entry in dir_info
}
needed_files = {info for info, _ in dir_info.items(path_info)}
redundant_files = existing_files - needed_files
for path in redundant_files:
self.safe_remove(path, force)
return bool(redundant_files)
@use_state
def checkout(
self,
path_info,
@@ -470,18 +504,19 @@ class CloudCache:
progress_callback=None,
relink=False,
filter_info=None,
quiet=False,
):
if path_info.scheme not in ["local", self.tree.scheme]:
raise NotImplementedError
hash_ = hash_info.get(self.tree.PARAM_CHECKSUM)
failed = None
skip = False
if not hash_:
logger.warning(
"No file hash info found for '%s'. " "It won't be created.",
path_info,
)
if not hash_info:
if not quiet:
logger.warning(
"No file hash info found for '%s'. It won't be created.",
path_info,
)
self.safe_remove(path_info, force=force)
failed = path_info
@@ -490,13 +525,14 @@ class CloudCache:
skip = True
elif self.changed_cache(
hash_, path_info=path_info, filter_info=filter_info
hash_info, path_info=path_info, filter_info=filter_info
):
logger.warning(
"Cache '%s' not found. File '%s' won't be created.",
hash_,
path_info,
)
if not quiet:
logger.warning(
"Cache '%s' not found. File '%s' won't be created.",
hash_info,
path_info,
)
self.safe_remove(path_info, force=force)
failed = path_info
@@ -505,50 +541,397 @@ class CloudCache:
progress_callback(
str(path_info),
self.get_files_number(
self.tree.path_info, hash_, filter_info
self.tree.path_info, hash_info, filter_info
),
)
if failed:
raise CheckoutError([failed])
return
logger.debug("Checking out '%s' with cache '%s'.", path_info, hash_)
logger.debug(
"Checking out '%s' with cache '%s'.", path_info, hash_info
)
return self._checkout(
path_info, hash_, force, progress_callback, relink, filter_info,
path_info,
hash_info,
force,
progress_callback,
relink,
filter_info,
)
def _checkout(
self,
path_info,
hash_,
hash_info,
force=False,
progress_callback=None,
relink=False,
filter_info=None,
):
if not self.tree.is_dir_hash(hash_):
if not hash_info.isdir:
return self._checkout_file(
path_info, hash_, force, progress_callback, relink
path_info, hash_info, force, progress_callback, relink
)
return self._checkout_dir(
path_info, hash_, force, progress_callback, relink, filter_info
path_info, hash_info, force, progress_callback, relink, filter_info
)
def get_files_number(self, path_info, hash_, filter_info):
def get_files_number(self, path_info, hash_info, filter_info):
from funcy.py3 import ilen
if not hash_:
if not hash_info:
return 0
if not self.tree.is_dir_hash(hash_):
if not hash_info.isdir:
return 1
if not filter_info:
return len(self.get_dir_cache(hash_))
return self.get_dir_cache(hash_info).nfiles
return ilen(
filter_info.isin_or_eq(path_info / entry[self.tree.PARAM_CHECKSUM])
for entry in self.get_dir_cache(hash_)
filter_info.isin_or_eq(path_info / relpath)
for relpath, _ in self.get_dir_cache(hash_info).items()
)
def _get_dir_size(self, dir_info):
try:
return sum(
self.tree.getsize(self.tree.hash_to_path_info(hi.value))
for _, hi in dir_info.items()
)
except FileNotFoundError:
return None
def merge(self, ancestor_info, our_info, their_info):
assert our_info
assert their_info
if ancestor_info:
ancestor = self.get_dir_cache(ancestor_info)
else:
ancestor = DirInfo()
our = self.get_dir_cache(our_info)
their = self.get_dir_cache(their_info)
merged = our.merge(ancestor, their)
hash_info = self.save_dir_info(merged)
hash_info.size = self._get_dir_size(merged)
return hash_info
@use_state
def get_hash(self, tree, path_info):
hash_info = tree.get_hash(path_info)
if not hash_info.isdir:
assert hash_info.name == self.tree.PARAM_CHECKSUM
return hash_info
hi = self.save_dir_info(hash_info.dir_info, hash_info)
hi.size = hash_info.size
return hi
def set_dir_info(self, hash_info):
assert hash_info.isdir
hash_info.dir_info = self.get_dir_cache(hash_info)
hash_info.nfiles = hash_info.dir_info.nfiles
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
if len(prefix) > 2:
path_info = self.tree.path_info / prefix[:2] / prefix[2:]
else:
path_info = self.tree.path_info / prefix[:2]
prefix = True
else:
path_info = self.tree.path_info
prefix = False
if progress_callback:
for file_info in self.tree.walk_files(path_info, prefix=prefix):
progress_callback()
yield file_info.path
else:
yield from self.tree.walk_files(path_info, prefix=prefix)
def _path_to_hash(self, path):
parts = self.tree.PATH_CLS(path).parts[-2:]
if not (len(parts) == 2 and parts[0] and len(parts[0]) == 2):
raise ValueError(f"Bad cache file path '{path}'")
return "".join(parts)
def list_hashes(self, prefix=None, progress_callback=None):
"""Iterate over hashes in this tree.
If `prefix` is specified, only hashes which begin with `prefix`
will be returned.
"""
for path in self._list_paths(prefix, progress_callback):
try:
yield self._path_to_hash(path)
except ValueError:
logger.debug(
"'%s' doesn't look like a cache file, skipping", path
)
def _hashes_with_limit(self, limit, prefix=None, progress_callback=None):
count = 0
for hash_ in self.list_hashes(prefix, progress_callback):
yield hash_
count += 1
if count > limit:
logger.debug(
"`list_hashes()` returned max '{}' hashes, "
"skipping remaining results".format(limit)
)
return
def _max_estimation_size(self, hashes):
# Max remote size allowed for us to use traverse method
return max(
self.tree.TRAVERSE_THRESHOLD_SIZE,
len(hashes)
/ self.tree.TRAVERSE_WEIGHT_MULTIPLIER
* self.tree.LIST_OBJECT_PAGE_SIZE,
)
def _estimate_remote_size(self, hashes=None, name=None):
"""Estimate tree size based on number of entries beginning with
"00..." prefix.
"""
prefix = "0" * self.tree.TRAVERSE_PREFIX_LEN
total_prefixes = pow(16, self.tree.TRAVERSE_PREFIX_LEN)
if hashes:
max_hashes = self._max_estimation_size(hashes)
else:
max_hashes = None
with Tqdm(
desc="Estimating size of "
+ (f"cache in '{name}'" if name else "remote cache"),
unit="file",
) as pbar:
def update(n=1):
pbar.update(n * total_prefixes)
if max_hashes:
hashes = self._hashes_with_limit(
max_hashes / total_prefixes, prefix, update
)
else:
hashes = self.list_hashes(prefix, update)
remote_hashes = set(hashes)
if remote_hashes:
remote_size = total_prefixes * len(remote_hashes)
else:
remote_size = total_prefixes
logger.debug(f"Estimated remote size: {remote_size} files")
return remote_size, remote_hashes
def list_hashes_traverse(
self, remote_size, remote_hashes, jobs=None, name=None
):
"""Iterate over all hashes found in this tree.
Hashes are fetched in parallel according to prefix, except in
cases where the remote size is very small.
All hashes from the remote (including any from the size
estimation step passed via the `remote_hashes` argument) will be
returned.
NOTE: For large remotes the list of hashes will be very
big(e.g. 100M entries, md5 for each is 32 bytes, so ~3200Mb list)
and we don't really need all of it at the same time, so it makes
sense to use a generator to gradually iterate over it, without
keeping all of it in memory.
"""
num_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
if num_pages < 256 / self.tree.JOBS:
# Fetching prefixes in parallel requires at least 255 more
# requests, for small enough remotes it will be faster to fetch
# entire cache without splitting it into prefixes.
#
# NOTE: this ends up re-fetching hashes that were already
# fetched during remote size estimation
traverse_prefixes = [None]
initial = 0
else:
yield from remote_hashes
initial = len(remote_hashes)
traverse_prefixes = [f"{i:02x}" for i in range(1, 256)]
if self.tree.TRAVERSE_PREFIX_LEN > 2:
traverse_prefixes += [
"{0:0{1}x}".format(i, self.tree.TRAVERSE_PREFIX_LEN)
for i in range(
1, pow(16, self.tree.TRAVERSE_PREFIX_LEN - 2)
)
]
with Tqdm(
desc="Querying "
+ (f"cache in '{name}'" if name else "remote cache"),
total=remote_size,
initial=initial,
unit="file",
) as pbar:
def list_with_update(prefix):
return list(
self.list_hashes(
prefix=prefix, progress_callback=pbar.update
)
)
with ThreadPoolExecutor(
max_workers=jobs or self.tree.JOBS
) as executor:
in_remote = executor.map(list_with_update, traverse_prefixes,)
yield from itertools.chain.from_iterable(in_remote)
def all(self, jobs=None, name=None):
"""Iterate over all hashes in this tree.
Hashes will be fetched in parallel threads according to prefix
(except for small remotes) and a progress bar will be displayed.
"""
logger.debug(
"Fetching all hashes from '{}'".format(
name if name else "remote cache"
)
)
if not self.tree.CAN_TRAVERSE:
return self.list_hashes()
remote_size, remote_hashes = self._estimate_remote_size(name=name)
return self.list_hashes_traverse(
remote_size, remote_hashes, jobs, name
)
def _remove_unpacked_dir(self, hash_):
pass
def gc(self, used, jobs=None):
removed = False
# hashes must be sorted to ensure we always remove .dir files first
for hash_ in sorted(
self.all(jobs, str(self.tree.path_info)),
key=self.tree.is_dir_hash,
reverse=True,
):
if hash_ in used:
continue
path_info = self.tree.hash_to_path_info(hash_)
if self.tree.is_dir_hash(hash_):
# backward compatibility
# pylint: disable=protected-access
self._remove_unpacked_dir(hash_)
self.tree.remove(path_info)
removed = True
return removed
def list_hashes_exists(self, hashes, jobs=None, name=None):
"""Return list of the specified hashes which exist in this tree.
Hashes will be queried individually.
"""
logger.debug(
"Querying {} hashes via object_exists".format(len(hashes))
)
with Tqdm(
desc="Querying "
+ ("cache in " + name if name else "remote cache"),
total=len(hashes),
unit="file",
) as pbar:
def exists_with_progress(path_info):
ret = self.tree.exists(path_info)
pbar.update_msg(str(path_info))
return ret
with ThreadPoolExecutor(
max_workers=jobs or self.tree.JOBS
) as executor:
path_infos = map(self.tree.hash_to_path_info, hashes)
in_remote = executor.map(exists_with_progress, path_infos)
ret = list(itertools.compress(hashes, in_remote))
return ret
def hashes_exist(self, hashes, jobs=None, name=None):
"""Check if the given hashes are stored in the remote.
There are two ways of performing this check:
- Traverse method: Get a list of all the files in the remote
(traversing the cache directory) and compare it with
the given hashes. Cache entries will be retrieved in parallel
threads according to prefix (i.e. entries starting with, "00...",
"01...", and so on) and a progress bar will be displayed.
- Exists method: For each given hash, run the `exists`
method and filter the hashes that aren't on the remote.
This is done in parallel threads.
It also shows a progress bar when performing the check.
The reason for such an odd logic is that most of the remotes
take much shorter time to just retrieve everything they have under
a certain prefix (e.g. s3, gs, ssh, hdfs). Other remotes that can
check if particular file exists much quicker, use their own
implementation of hashes_exist (see ssh, local).
Which method to use will be automatically determined after estimating
the size of the remote cache, and comparing the estimated size with
len(hashes). To estimate the size of the remote cache, we fetch
a small subset of cache entries (i.e. entries starting with "00...").
Based on the number of entries in that subset, the size of the full
cache can be estimated, since the cache is evenly distributed according
to hash.
Returns:
A list with hashes that were found in the remote
"""
# Remotes which do not use traverse prefix should override
# hashes_exist() (see ssh, local)
assert self.tree.TRAVERSE_PREFIX_LEN >= 2
hashes = set(hashes)
if len(hashes) == 1 or not self.tree.CAN_TRAVERSE:
remote_hashes = self.list_hashes_exists(hashes, jobs, name)
return remote_hashes
# Max remote size allowed for us to use traverse method
remote_size, remote_hashes = self._estimate_remote_size(hashes, name)
traverse_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
# For sufficiently large remotes, traverse must be weighted to account
# for performance overhead from large lists/sets.
# From testing with S3, for remotes with 1M+ files, object_exists is
# faster until len(hashes) is at least 10k~100k
if remote_size > self.tree.TRAVERSE_THRESHOLD_SIZE:
traverse_weight = (
traverse_pages * self.tree.TRAVERSE_WEIGHT_MULTIPLIER
)
else:
traverse_weight = traverse_pages
if len(hashes) < traverse_weight:
logger.debug(
"Large remote ('{}' hashes < '{}' traverse weight), "
"using object_exists for remaining hashes".format(
len(hashes), traverse_weight
)
)
return list(hashes & remote_hashes) + self.list_hashes_exists(
hashes - remote_hashes, jobs, name
)
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
remote_hashes = set(
self.list_hashes_traverse(remote_size, remote_hashes, jobs, name)
)
return list(hashes & set(remote_hashes))
+27 -367
View File
@@ -1,52 +1,23 @@
import errno
import logging
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial, wraps
from funcy import cached_property, concat
from funcy import cached_property
from dvc.exceptions import DownloadError, UploadError
from dvc.hash_info import HashInfo
from dvc.path_info import PathInfo
from dvc.progress import Tqdm
from ..remote.base import index_locked
from ..tree.local import LocalTree
from .base import (
STATUS_DELETED,
STATUS_MAP,
STATUS_MISSING,
STATUS_NEW,
CloudCache,
)
from ..utils.fs import walk_files
from .base import CloudCache
logger = logging.getLogger(__name__)
def _log_exceptions(func, operation):
@wraps(func)
def wrapper(from_info, to_info, *args, **kwargs):
try:
func(from_info, to_info, *args, **kwargs)
return 0
except Exception as exc: # pylint: disable=broad-except
# NOTE: this means we ran out of file descriptors and there is no
# reason to try to proceed, as we will hit this error anyways.
# pylint: disable=no-member
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
raise
logger.exception(
"failed to %s '%s' to '%s'", operation, from_info, to_info
)
return 1
return wrapper
class LocalCache(CloudCache):
DEFAULT_CACHE_TYPES = ["reflink", "copy"]
CACHE_MODE = LocalTree.CACHE_MODE
UNPACKED_DIR_SUFFIX = ".unpacked"
def __init__(self, tree):
super().__init__(tree)
@@ -60,10 +31,6 @@ class LocalCache(CloudCache):
def cache_dir(self, value):
self.tree.path_info = PathInfo(value) if value else None
@classmethod
def supported(cls, config): # pylint: disable=unused-argument
return True
@cached_property
def cache_path(self):
return os.path.abspath(self.cache_dir)
@@ -85,18 +52,15 @@ class LocalCache(CloudCache):
desc="Querying "
+ ("cache in " + name if name else "local cache"),
)
if not self.changed_cache_file(hash_)
if not self.changed_cache_file(
HashInfo(self.tree.PARAM_CHECKSUM, hash_)
)
]
def already_cached(self, path_info):
assert path_info.scheme in ["", "local"]
current_md5 = self.tree.get_hash(path_info)
if not current_md5:
return False
return not self.changed_cache(current_md5)
return super().already_cached(path_info)
def _verify_link(self, path_info, link_type):
if link_type == "hardlink" and self.tree.getsize(path_info) == 0:
@@ -104,328 +68,24 @@ class LocalCache(CloudCache):
super()._verify_link(path_info, link_type)
@index_locked
def status(
self,
named_cache,
remote,
jobs=None,
show_checksums=False,
download=False,
):
# Return flattened dict containing all status info
dir_status, file_status, _ = self._status(
named_cache,
remote,
jobs=jobs,
show_checksums=show_checksums,
download=download,
)
return dict(dir_status, **file_status)
def _status(
self,
named_cache,
remote,
jobs=None,
show_checksums=False,
download=False,
):
"""Return a tuple of (dir_status_info, file_status_info, dir_contents).
dir_status_info contains status for .dir files, file_status_info
contains status for all other files, and dir_contents is a dict of
{dir_hash: set(file_hash, ...)} which can be used to map
a .dir file to its file contents.
"""
logger.debug(
f"Preparing to collect status from {remote.tree.path_info}"
)
md5s = set(named_cache.scheme_keys(self.tree.scheme))
logger.debug("Collecting information from local cache...")
local_exists = frozenset(
self.hashes_exist(md5s, jobs=jobs, name=self.cache_dir)
)
# This is a performance optimization. We can safely assume that,
# if the resources that we want to fetch are already cached,
# there's no need to check the remote storage for the existence of
# those files.
if download and local_exists == md5s:
remote_exists = local_exists
def _list_paths(self, prefix=None, progress_callback=None):
assert self.tree.path_info is not None
if prefix:
path_info = self.tree.path_info / prefix[:2]
if not self.tree.exists(path_info):
return
else:
logger.debug("Collecting information from remote cache...")
remote_exists = set()
dir_md5s = set(named_cache.dir_keys(self.tree.scheme))
if dir_md5s:
remote_exists.update(
self._indexed_dir_hashes(named_cache, remote, dir_md5s)
)
md5s.difference_update(remote_exists)
if md5s:
remote_exists.update(
remote.hashes_exist(
md5s, jobs=jobs, name=str(remote.tree.path_info)
)
)
return self._make_status(
named_cache, show_checksums, local_exists, remote_exists
)
def _make_status(
self, named_cache, show_checksums, local_exists, remote_exists
):
def make_names(hash_, names):
return {"name": hash_ if show_checksums else " ".join(names)}
dir_status = {}
file_status = {}
dir_contents = {}
for hash_, item in named_cache[self.tree.scheme].items():
if item.children:
dir_status[hash_] = make_names(hash_, item.names)
dir_contents[hash_] = set()
for child_hash, child in item.children.items():
file_status[child_hash] = make_names(
child_hash, child.names
)
dir_contents[hash_].add(child_hash)
else:
file_status[hash_] = make_names(hash_, item.names)
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))
return dir_status, file_status, dir_contents
def _indexed_dir_hashes(self, named_cache, remote, dir_md5s):
# Validate our index by verifying all indexed .dir hashes
# still exist on the remote
indexed_dirs = set(remote.index.dir_hashes())
indexed_dir_exists = set()
if indexed_dirs:
indexed_dir_exists.update(
remote.tree.list_hashes_exists(indexed_dirs)
)
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
if missing_dirs:
logger.debug(
"Remote cache missing indexed .dir hashes '{}', "
"clearing remote index".format(", ".join(missing_dirs))
)
remote.index.clear()
# Check if non-indexed (new) dir hashes exist on remote
dir_exists = dir_md5s.intersection(indexed_dir_exists)
dir_exists.update(
remote.tree.list_hashes_exists(dir_md5s - dir_exists)
)
# If .dir hash exists on the remote, assume directory contents
# still exists on the remote
for dir_hash in dir_exists:
file_hashes = list(
named_cache.child_keys(self.tree.scheme, dir_hash)
)
if dir_hash not in remote.index:
logger.debug(
"Indexing new .dir '{}' with '{}' nested files".format(
dir_hash, len(file_hashes)
)
)
remote.index.update([dir_hash], file_hashes)
yield dir_hash
yield from file_hashes
@staticmethod
def _fill_statuses(hash_info_dir, local_exists, remote_exists):
# Using sets because they are way faster for lookups
local = set(local_exists)
remote = set(remote_exists)
for md5, info in hash_info_dir.items():
status = STATUS_MAP[(md5 in local, md5 in remote)]
info["status"] = status
def _get_plans(self, download, remote, status_info, status):
cache = []
path_infos = []
names = []
hashes = []
for md5, info in Tqdm(
status_info.items(), desc="Analysing status", unit="file"
):
if info["status"] == status:
cache.append(self.tree.hash_to_path_info(md5))
path_infos.append(remote.tree.hash_to_path_info(md5))
names.append(info["name"])
hashes.append(md5)
if download:
to_infos = cache
from_infos = path_infos
path_info = self.tree.path_info
# NOTE: use utils.fs walk_files since tree.walk_files will not follow
# symlinks
if progress_callback:
for path in walk_files(path_info):
progress_callback()
yield path
else:
to_infos = path_infos
from_infos = cache
yield from walk_files(path_info)
return from_infos, to_infos, names, hashes
def _process(
self,
named_cache,
remote,
jobs=None,
show_checksums=False,
download=False,
):
logger.debug(
"Preparing to {} '{}'".format(
"download data from" if download else "upload data to",
remote.tree.path_info,
)
)
if download:
func = partial(
_log_exceptions(remote.tree.download, "download"),
dir_mode=self.tree.dir_mode,
file_mode=self.tree.file_mode,
)
status = STATUS_DELETED
desc = "Downloading"
else:
func = _log_exceptions(remote.tree.upload, "upload")
status = STATUS_NEW
desc = "Uploading"
if jobs is None:
jobs = remote.tree.JOBS
dir_status, file_status, dir_contents = self._status(
named_cache,
remote,
jobs=jobs,
show_checksums=show_checksums,
download=download,
)
dir_plans = self._get_plans(download, remote, dir_status, status)
file_plans = self._get_plans(download, remote, file_status, status)
total = len(dir_plans[0]) + len(file_plans[0])
if total == 0:
return 0
with Tqdm(total=total, unit="file", desc=desc) as pbar:
func = pbar.wrap_fn(func)
with ThreadPoolExecutor(max_workers=jobs) as executor:
if download:
from_infos, to_infos, names, _ = (
d + f for d, f in zip(dir_plans, file_plans)
)
fails = sum(
executor.map(func, from_infos, to_infos, names)
)
else:
# for uploads, push files first, and any .dir files last
file_futures = {}
for from_info, to_info, name, hash_ in zip(*file_plans):
file_futures[hash_] = executor.submit(
func, from_info, to_info, name
)
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,
)
fails = sum(
future.result()
for future in concat(
file_futures.values(), dir_futures.values()
)
)
if fails:
if download:
remote.index.clear()
raise DownloadError(fails)
raise UploadError(fails)
if not download:
# index successfully pushed dirs
for dir_hash, future in dir_futures.items():
if future.result() == 0:
file_hashes = dir_contents[dir_hash]
logger.debug(
"Indexing pushed dir '{}' with "
"'{}' nested files".format(dir_hash, len(file_hashes))
)
remote.index.update([dir_hash], file_hashes)
return len(dir_plans[0]) + len(file_plans[0])
@staticmethod
def _dir_upload(func, futures, from_info, to_info, name):
for future in as_completed(futures):
if future.result():
# do not upload this .dir file if any file in this
# directory failed to upload
logger.debug(
"failed to upload full contents of '{}', "
"aborting .dir file upload".format(name)
)
logger.error(f"failed to upload '{from_info}' to '{to_info}'")
return 1
return func(from_info, to_info, name)
@index_locked
def push(self, named_cache, remote, jobs=None, show_checksums=False):
return self._process(
named_cache,
remote,
jobs=jobs,
show_checksums=show_checksums,
download=False,
)
@index_locked
def pull(self, named_cache, remote, jobs=None, show_checksums=False):
return self._process(
named_cache,
remote,
jobs=jobs,
show_checksums=show_checksums,
download=True,
)
@staticmethod
def _log_missing_caches(hash_info_dict):
missing_caches = [
(md5, info)
for md5, info in hash_info_dict.items()
if info["status"] == STATUS_MISSING
]
if missing_caches:
missing_desc = "\n".join(
"name: {}, md5: {}".format(info["name"], md5)
for md5, info in missing_caches
)
msg = (
"Some of the cache files do not exist neither locally "
"nor on remote. Missing cache files:\n{}".format(missing_desc)
)
logger.warning(msg)
def _remove_unpacked_dir(self, hash_):
info = self.tree.hash_to_path_info(hash_)
path_info = info.with_name(info.name + self.UNPACKED_DIR_SUFFIX)
self.tree.remove(path_info)
+20 -3
View File
@@ -1,17 +1,18 @@
import errno
import itertools
import logging
import posixpath
from concurrent.futures import ThreadPoolExecutor
from dvc.progress import Tqdm
from dvc.utils import to_chunks
from .base import Remote
from .base import CloudCache
logger = logging.getLogger(__name__)
class SSHRemote(Remote):
class SSHCache(CloudCache):
def batch_exists(self, path_infos, callback):
def _exists(chunk_and_channel):
chunk, channel = chunk_and_channel
@@ -47,7 +48,7 @@ class SSHRemote(Remote):
remote/base.
"""
if not self.tree.CAN_TRAVERSE:
return list(set(hashes) & set(self.tree.all()))
return list(set(hashes) & set(self.all()))
# possibly prompt for credentials before "Querying" progress output
self.tree.ensure_credentials()
@@ -71,3 +72,19 @@ class SSHRemote(Remote):
in_remote = itertools.chain.from_iterable(results)
ret = list(itertools.compress(hashes, in_remote))
return ret
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
root = posixpath.join(self.tree.path_info.path, prefix[:2])
else:
root = self.tree.path_info.path
with self.tree.ssh(self.tree.path_info) as ssh:
if prefix and not ssh.exists(root):
return
# If we simply return an iterator then with above closes instantly
if progress_callback:
for path in ssh.walk_files(root):
progress_callback()
yield path
else:
yield from ssh.walk_files(root)
+23
View File
@@ -1,11 +1,13 @@
"""DVC command line interface"""
import argparse
import logging
import os
import sys
from .command import (
add,
cache,
check_ignore,
checkout,
commit,
completion,
@@ -79,6 +81,7 @@ COMMANDS = [
git_hook,
plots,
experiments,
check_ignore,
]
@@ -135,6 +138,18 @@ 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)
parent_parser.add_argument(
"--pdb", action="store_true", default=False, 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."
@@ -181,6 +196,14 @@ def get_main_parser():
help="Show program's version.",
)
parser.add_argument(
"--cd",
default=os.path.curdir,
metavar="<path>",
help="Change to directory before executing.",
type=str,
)
# Sub commands
subparsers = parser.add_subparsers(
title="Available Commands",
+17
View File
@@ -20,6 +20,8 @@ class CmdAdd(CmdBase):
no_commit=self.args.no_commit,
fname=self.args.file,
external=self.args.external,
glob=self.args.glob,
desc=self.args.desc,
)
except DvcException:
@@ -57,11 +59,26 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Allow targets that are outside of the DVC repository.",
)
parser.add_argument(
"--glob",
action="store_true",
default=False,
help="Allows targets containing shell-style wildcards.",
)
parser.add_argument(
"--file",
help="Specify name of the DVC-file this command will generate.",
metavar="<filename>",
)
parser.add_argument(
"--desc",
type=str,
metavar="<text>",
help=(
"User description of the data (optional). "
"This doesn't affect any DVC operations."
),
)
parser.add_argument(
"targets", nargs="+", help="Input files/directories to add.",
).complete = completion.FILE
+12 -4
View File
@@ -1,4 +1,5 @@
import logging
import os
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
@@ -28,16 +29,21 @@ def append_doc_link(help_message, path):
class CmdBase(ABC):
UNINITIALIZED = False
def __init__(self, args):
from dvc.repo import Repo
from dvc.updater import Updater
self.repo = Repo()
os.chdir(args.cd)
self.repo = Repo(uninitialized=self.UNINITIALIZED)
self.config = self.repo.config
self.args = args
hardlink_lock = self.config["core"].get("hardlink_lock", False)
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
updater.check()
if self.repo.tmp_dir:
hardlink_lock = self.config["core"].get("hardlink_lock", False)
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
updater.check()
@property
def default_targets(self):
@@ -55,3 +61,5 @@ class CmdBase(ABC):
class CmdBaseNoRepo(CmdBase):
def __init__(self, args): # pylint: disable=super-init-not-called
self.args = args
os.chdir(args.cd)
+9 -1
View File
@@ -1,12 +1,18 @@
import argparse
import logging
from dvc.command import completion
from dvc.command.base import append_doc_link, fix_subparsers
from dvc.command.config import CmdConfig
logger = logging.getLogger(__name__)
class CmdCacheDir(CmdConfig):
def run(self):
if self.args.value is None and not self.args.unset:
logger.info(self.config["cache"]["dir"])
return 0
with self.config.edit(level=self.args.level) as edit:
edit["cache"]["dir"] = self.args.value
return 0
@@ -55,6 +61,8 @@ def add_parser(subparsers, parent_parser):
"value",
help="Path to cache directory. Relative paths are resolved relative "
"to the current directory and saved to config relative to the "
"config file location.",
"config file location. If no path is provided, it returns the "
"current cache directory.",
nargs="?",
).complete = completion.DIR
cache_dir_parser.set_defaults(func=CmdCacheDir)
+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()
+75 -41
View File
@@ -2,14 +2,14 @@ import argparse
import logging
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException
from dvc.stage import Stage
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()
@@ -29,57 +30,83 @@ def _show_dot(G):
return dot_file.getvalue()
def _build(G, target=None, full=False):
def _collect_targets(repo, target, outs):
if not target:
return []
pairs = repo.collect_granular(target)
if not outs:
return [stage.addressing for stage, _ in pairs]
targets = []
for stage, info in pairs:
if not info:
targets.extend([str(out) for out in stage.outs])
continue
for out in repo.outs_trie.itervalues(prefix=info.parts): # noqa: B301
targets.extend(str(out))
return targets
def _transform(repo, outs):
import networkx as nx
from dvc.repo.graph import get_pipeline, get_pipelines
if target:
H = get_pipeline(get_pipelines(G), target)
if not full:
descendants = nx.descendants(G, target)
def _relabel(node) -> str:
return node.addressing if isinstance(node, Stage) else str(node)
G = repo.outs_graph if outs else repo.graph
return nx.relabel_nodes(G, _relabel, copy=True)
def _filter(G, targets, full):
import networkx as nx
if not targets:
return G
H = G.copy()
if not full:
descendants = set()
for target in targets:
descendants.update(nx.descendants(G, target))
descendants.add(target)
H.remove_nodes_from(set(G.nodes()) - descendants)
else:
H = G
H.remove_nodes_from(set(G.nodes()) - descendants)
def _relabel(stage):
return stage.addressing
undirected = H.to_undirected()
connected = set()
for target in targets:
connected.update(nx.node_connected_component(undirected, target))
return nx.relabel_nodes(H, _relabel, copy=False)
H.remove_nodes_from(set(H.nodes()) - connected)
return H
def _build(repo, target=None, full=False, outs=False):
targets = _collect_targets(repo, target, outs)
G = _transform(repo, outs)
return _filter(G, targets, full)
class CmdDAG(CmdBase):
def run(self):
try:
target = None
if self.args.target:
stages = self.repo.collect(self.args.target)
if len(stages) > 1:
logger.error(
f"'{self.args.target}' contains more than one stage "
"{stages}, please specify one stage"
)
return 1
target = stages[0]
G = _build(
self.repo,
target=self.args.target,
full=self.args.full,
outs=self.args.outs,
)
G = _build(self.repo.graph, target=target, full=self.args.full,)
if self.args.dot:
logger.info(_show_dot(G))
else:
from dvc.utils.pager import pager
if self.args.dot:
logger.info(_show_dot(G))
else:
from dvc.utils.pager import pager
pager(_show_ascii(G))
pager(_show_ascii(G))
return 0
except DvcException:
msg = "failed to show "
if self.args.target:
msg += f"a pipeline for '{target}'"
else:
msg += "pipelines"
logger.exception(msg)
return 1
return 0
def add_parser(subparsers, parent_parser):
@@ -106,6 +133,13 @@ def add_parser(subparsers, parent_parser):
"showing DAG consisting only of ancestors."
),
)
dag_parser.add_argument(
"-o",
"--outs",
action="store_true",
default=False,
help="Print output files instead of stages.",
)
dag_parser.add_argument(
"target",
nargs="?",
+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
+52 -21
View File
@@ -11,30 +11,43 @@ 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, hide_missing=False):
from dvc.utils.diff import table
header = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
rows = []
for status in ["added", "deleted", "modified"]:
statuses = ["added", "deleted", "modified"]
if not hide_missing:
statuses.append("not in cache")
for status in statuses:
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):
@staticmethod
def _format(diff):
def _format(diff, hide_missing=False):
"""
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,21 +68,20 @@ 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,
"deleted": colorama.Fore.RED,
"not in cache": colorama.Fore.YELLOW,
}
summary = {}
groups = []
for state in ["added", "deleted", "modified"]:
states = ["added", "deleted", "modified"]
if not hide_missing:
states.append("not in cache")
for state in states:
summary[state] = 0
entries = diff[state]
@@ -100,28 +112,42 @@ class CmdDiff(CmdBase):
)
)
groups.append(
if not sum(summary.values()):
return None
fmt = (
"files summary: {added} added, {deleted} deleted,"
" {modified} modified".format_map(summary)
" {modified} modified"
)
if not hide_missing:
fmt += ", {not in cache} not in cache"
groups.append(fmt.format_map(summary))
return "\n\n".join(groups)
def run(self):
try:
diff = self.repo.diff(self.args.a_rev, self.args.b_rev)
show_hash = self.args.show_hash
hide_missing = self.args.b_rev or self.args.hide_missing
if hide_missing:
del diff["not in cache"]
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, hide_missing))
elif diff:
logger.info(self._format(diff))
output = self._format(diff, hide_missing)
if output:
logger.info(output)
except DvcException:
logger.exception("failed to get diff")
@@ -170,4 +196,9 @@ def add_parser(subparsers, parent_parser):
action="store_true",
default=False,
)
diff_parser.add_argument(
"--hide-missing",
help="Hide missing cache file status.",
action="store_true",
)
diff_parser.set_defaults(func=CmdDiff)
+593 -85
View File
@@ -1,27 +1,77 @@
import argparse
import io
import logging
import os
from collections import OrderedDict
from collections.abc import Mapping
from datetime import date, datetime
from itertools import groupby
from typing import Iterable, Optional
import dvc.prompt as prompt
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.command.repro import CmdRepro
from dvc.command.repro import add_arguments as add_repro_arguments
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.repo.experiments import Experiments
from dvc.scm.git import Git
from dvc.utils.flatten import flatten
logger = logging.getLogger(__name__)
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({match: None for match in matches})
if include:
ret: OrderedDict = OrderedDict()
_filter(include, ret.update)
else:
ret = OrderedDict({name: None for name in names})
if exclude:
_filter(exclude, ret.difference_update) # type: ignore[attr-defined]
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)
names[name] = None
def _collect_names(all_experiments):
def _collect_names(all_experiments, **kwargs):
metric_names = set()
param_names = set()
@@ -30,84 +80,223 @@ def _collect_names(all_experiments):
_update_names(metric_names, exp.get("metrics", {}).items())
_update_names(param_names, exp.get("params", {}).items())
return sorted(metric_names), sorted(param_names)
metric_names = _filter_names(
sorted(metric_names),
"metrics",
kwargs.get("include_metrics"),
kwargs.get("exclude_metrics"),
)
param_names = _filter_names(
sorted(param_names),
"params",
kwargs.get("include_params"),
kwargs.get("exclude_params"),
)
return metric_names, 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,
sort_by=None,
sort_order=None,
):
from flatten_json import flatten
if precision is None:
precision = DEFAULT_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 sort_by:
if sort_by in metric_names:
sort_type = "metrics"
elif sort_by in param_names:
sort_type = "params"
else:
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
reverse = sort_order == "desc"
experiments = _sort_exp(experiments, sort_by, sort_type, reverse)
new_checkpoint = True
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]}")
else:
row.append(f"└── {rev[:7]}")
queued = "*" if exp.get("queued", False) else ""
_extend(row, metric_names, exp.get("metrics", {}).items())
_extend(row, param_names, exp.get("params", {}).items())
tip = exp.get("checkpoint_tip")
parent = ""
if rev == "baseline":
if Git.is_sha(base_rev):
name_rev = base_rev[:7]
else:
name_rev = base_rev
name = exp.get("name", name_rev)
row.append(f"{name}")
style = "bold"
else:
if tip:
parent_rev = exp.get("checkpoint_parent", "")
parent_exp = experiments.get(parent_rev, {})
parent_tip = parent_exp.get("checkpoint_tip")
if tip == parent_tip:
if new_checkpoint:
tree = "│ ╓"
else:
tree = "│ ╟"
new_checkpoint = False
else:
if parent_rev == base_rev:
tree = "├─╨"
else:
tree = "│ ╟"
parent = f" ({parent_rev[:7]})"
new_checkpoint = True
else:
if i < len(experiments) - 1:
tree = "├──"
else:
tree = "└──"
new_checkpoint = True
row.append(f"{tree} {queued}{rev[:7]}{parent}")
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 _show_experiments(all_experiments, console, precision=None):
from rich.table import Table
from dvc.scm.git import Git
def _sort_exp(experiments, sort_by, typ, reverse):
def _sort(item):
rev, exp = item
tip = exp.get("checkpoint_tip")
if tip and tip != rev:
# Sort checkpoint experiments by tip commit
return _sort((tip, experiments[tip]))
for fname, item in exp.get(typ, {}).items():
if isinstance(item, dict):
item = flatten(item)
else:
item = {fname: item}
if sort_by in item:
val = item[sort_by]
return (val is None, val)
return (True, None)
metric_names, param_names = _collect_names(all_experiments)
ret = OrderedDict()
if "baseline" in experiments:
ret["baseline"] = experiments.pop("baseline")
ret.update(sorted(experiments.items(), key=_sort, reverse=reverse))
return ret
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 _format_field(val, precision=DEFAULT_PRECISION):
if isinstance(val, float):
fmt = f"{{:.{precision}g}}"
return fmt.format(val)
elif isinstance(val, Mapping):
return {k: _format_field(v) for k, v in val.items()}
elif isinstance(val, list):
return [_format_field(x) for x in val]
return str(val)
def _extend_row(row, names, items, precision):
from rich.text import Text
if not items:
row.extend(["-"] * len(names))
return
for fname, item in items:
if isinstance(item, dict):
item = flatten(item)
else:
item = {fname: item}
for name in names:
if name in item:
value = item[name]
if value is None:
text = "-"
else:
# wrap field data in rich.Text, otherwise rich may
# interpret unescaped braces from list/dict types as rich
# markup tags
text = Text(str(_format_field(value, precision)))
row.append(text)
else:
row.append("-")
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
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")
for base_rev, experiments in all_experiments.items():
if Git.is_sha(base_rev):
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)
console.print(table)
def _format_json(item):
if isinstance(item, (date, datetime)):
return item.isoformat()
raise TypeError
class CmdExperimentsShow(CmdBase):
def run(self):
from rich.console import Console
from dvc.utils.pager import pager
if not self.repo.experiments:
@@ -118,17 +307,44 @@ 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.show_json:
import json
logger.info(json.dumps(all_experiments, default=_format_json))
return 0
if self.args.no_pager:
console = Console()
else:
# Note: rich does not currently include a native way to force
# infinite width for use with a pager
console = Console(
file=io.StringIO(), force_terminal=True, width=9999
)
if self.args.precision is None:
precision = DEFAULT_PRECISION
else:
precision = self.args.precision
_show_experiments(
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,
sort_by=self.args.sort_by,
sort_order=self.args.sort_order,
precision=precision,
)
_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,27 +357,21 @@ 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
def _show_diff(
diff, title="", markdown=False, no_path=False, old=False, precision=None
diff,
title="",
markdown=False,
no_path=False,
old=False,
precision=DEFAULT_PRECISION,
):
from dvc.utils.diff import table
if precision is None:
precision = DEFAULT_PRECISION
def _round(val):
if isinstance(val, float):
return round(val, precision)
return val
rows = []
for fname, diff_ in diff.items():
sorted_diff = OrderedDict(sorted(diff_.items()))
@@ -169,9 +379,13 @@ def _show_diff(
row = [] if no_path else [fname]
row.append(item)
if old:
row.append(_round(change.get("old")))
row.append(_round(change["new"]))
row.append(_round(change.get("diff", "diff not supported")))
row.append(_format_field(change.get("old"), precision))
row.append(_format_field(change["new"], precision))
row.append(
_format_field(
change.get("diff", "diff not supported"), precision
)
)
rows.append(row)
header = [] if no_path else ["Path"]
@@ -202,6 +416,11 @@ class CmdExperimentsDiff(CmdBase):
logger.info(json.dumps(diff))
else:
if self.args.precision is None:
precision = DEFAULT_PRECISION
else:
precision = self.args.precision
diffs = [("metrics", "Metric"), ("params", "Param")]
for key, title in diffs:
table = _show_diff(
@@ -210,7 +429,7 @@ class CmdExperimentsDiff(CmdBase):
markdown=self.args.show_md,
no_path=self.args.no_path,
old=self.args.old,
precision=self.args.precision,
precision=precision,
)
if table:
logger.info(table)
@@ -223,12 +442,103 @@ class CmdExperimentsDiff(CmdBase):
return 0
class CmdExperimentsRun(CmdRepro):
def run(self):
if not self.repo.experiments:
return 0
saved_dir = os.path.realpath(os.curdir)
os.chdir(self.args.cwd)
# Dirty hack so the for loop below can at least enter once
if self.args.all_pipelines:
self.args.targets = [None]
elif not self.args.targets:
self.args.targets = self.default_targets
ret = 0
for target in self.args.targets:
try:
self.repo.experiments.run(
target,
queue=self.args.queue,
run_all=self.args.run_all,
jobs=self.args.jobs,
params=self.args.params,
checkpoint_resume=self.args.checkpoint_resume,
**self._repro_kwargs,
)
except DvcException:
logger.exception("")
ret = 1
break
os.chdir(saved_dir)
return ret
class CmdExperimentsGC(CmdRepro):
def run(self):
from dvc.repo.gc import _raise_error_if_all_disabled
if not self.repo.experiments:
return 0
_raise_error_if_all_disabled(
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
workspace=self.args.workspace,
)
msg = "This will remove all experiments except those derived from "
msg += "the workspace"
if self.args.all_commits:
msg += " and all git commits"
elif self.args.all_branches and self.args.all_tags:
msg += " and all git branches and tags"
elif self.args.all_branches:
msg += " and all git branches"
elif self.args.all_tags:
msg += " and all git tags"
msg += " of the current repo."
if self.args.queued:
msg += " Run queued experiments will be preserved."
if self.args.queued:
msg += " Run queued experiments will be removed."
logger.warning(msg)
msg = "Are you sure you want to proceed?"
if not self.args.force and not prompt.confirm(msg):
return 1
removed = self.repo.experiments.gc(
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
workspace=self.args.workspace,
queued=self.args.queued,
)
if removed:
logger.info(
f"Removed {removed} experiments. To remove unused cache files "
"use 'dvc gc'."
)
else:
logger.info("No experiments to remove.")
return 0
def add_parser(subparsers, parent_parser):
EXPERIMENTS_HELP = "Commands to display and compare experiments."
experiments_parser = subparsers.add_parser(
"experiments",
parents=[parent_parser],
aliases=["exp"],
description=append_doc_link(EXPERIMENTS_HELP, "experiments"),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
@@ -269,6 +579,78 @@ 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(
"--sort-by",
help="Sort related experiments by the specified metric or param.",
metavar="<metric/param>",
)
experiments_show_parser.add_argument(
"--sort-order",
help="Sort order to use with --sort-by.",
choices=("asc", "desc"),
default="asc",
)
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.add_argument(
"--show-json",
action="store_true",
default=False,
help="Print output in JSON format instead of a human-readable table.",
)
experiments_show_parser.add_argument(
"--precision",
type=int,
help=(
"Round metrics/params to `n` digits precision after the decimal "
f"point. Rounds to {DEFAULT_PRECISION} digits by default."
),
metavar="<n>",
)
experiments_show_parser.set_defaults(func=CmdExperimentsShow)
EXPERIMENTS_CHECKOUT_HELP = "Checkout experiments."
@@ -281,14 +663,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.",
)
@@ -352,3 +726,137 @@ def add_parser(subparsers, parent_parser):
metavar="<n>",
)
experiments_diff_parser.set_defaults(func=CmdExperimentsDiff)
EXPERIMENTS_RUN_HELP = (
"Reproduce complete or partial experiment pipelines."
)
experiments_run_parser = experiments_subparsers.add_parser(
"run",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_RUN_HELP, "experiments/run"),
help=EXPERIMENTS_RUN_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
_add_run_common(experiments_run_parser)
experiments_run_parser.add_argument(
"--checkpoint-resume", type=str, default=None, help=argparse.SUPPRESS,
)
experiments_run_parser.set_defaults(func=CmdExperimentsRun)
EXPERIMENTS_RESUME_HELP = "Resume checkpoint experiments."
experiments_resume_parser = experiments_subparsers.add_parser(
"resume",
parents=[parent_parser],
aliases=["res"],
description=append_doc_link(
EXPERIMENTS_RESUME_HELP, "experiments/resume"
),
help=EXPERIMENTS_RESUME_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
_add_run_common(experiments_resume_parser)
experiments_resume_parser.add_argument(
"-r",
"--rev",
type=str,
default=Experiments.LAST_CHECKPOINT,
dest="checkpoint_resume",
help=(
"Continue the specified checkpoint experiment. "
"If no experiment revision is provided, "
"the most recently run checkpoint experiment will be used."
),
metavar="<experiment_rev>",
)
experiments_resume_parser.set_defaults(func=CmdExperimentsRun)
EXPERIMENTS_GC_HELP = "Garbage collect unneeded experiments."
EXPERIMENTS_GC_DESCRIPTION = (
"Removes all experiments which are not derived from the specified"
"Git revisions."
)
experiments_gc_parser = experiments_subparsers.add_parser(
"gc",
parents=[parent_parser],
description=append_doc_link(
EXPERIMENTS_GC_DESCRIPTION, "experiments/gc"
),
help=EXPERIMENTS_GC_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_gc_parser.add_argument(
"-w",
"--workspace",
action="store_true",
default=False,
help="Keep experiments derived from the current workspace.",
)
experiments_gc_parser.add_argument(
"-a",
"--all-branches",
action="store_true",
default=False,
help="Keep experiments derived from the tips of all Git branches.",
)
experiments_gc_parser.add_argument(
"-T",
"--all-tags",
action="store_true",
default=False,
help="Keep experiments derived from all Git tags.",
)
experiments_gc_parser.add_argument(
"--all-commits",
action="store_true",
default=False,
help="Keep experiments derived from all Git commits.",
)
experiments_gc_parser.add_argument(
"--queued",
action="store_true",
default=False,
help=(
"Keep queued experiments (experiments run queue will be cleared "
"by default)."
),
)
experiments_gc_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Force garbage collection - automatically agree to all prompts.",
)
experiments_gc_parser.set_defaults(func=CmdExperimentsGC)
def _add_run_common(parser):
"""Add common args for 'exp run' and 'exp resume'."""
# inherit arguments from `dvc repro`
add_repro_arguments(parser)
parser.add_argument(
"--params",
action="append",
default=[],
help="Use the specified param values when reproducing pipelines.",
metavar="[<filename>:]<params_list>",
)
parser.add_argument(
"--queue",
action="store_true",
default=False,
help="Stage this experiment in the run queue for future execution.",
)
parser.add_argument(
"--run-all",
action="store_true",
default=False,
help="Execute all experiments in the run queue.",
)
parser.add_argument(
"-j",
"--jobs",
type=int,
help="Run the specified number of experiments at a time in parallel.",
metavar="<number>",
)
+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)
+23
View File
@@ -15,7 +15,10 @@ class CmdImport(CmdBase):
self.args.url,
self.args.path,
out=self.args.out,
fname=self.args.file,
rev=self.args.rev,
no_exec=self.args.no_exec,
desc=self.args.desc,
)
except DvcException:
logger.exception(
@@ -59,4 +62,24 @@ def add_parser(subparsers, parent_parser):
help="Git revision (e.g. SHA, branch, tag)",
metavar="<commit>",
)
import_parser.add_argument(
"--file",
help="Specify name of the DVC-file this command will generate.",
metavar="<filename>",
)
import_parser.add_argument(
"--no-exec",
action="store_true",
default=False,
help="Only create DVC-file without actually downloading it.",
)
import_parser.add_argument(
"--desc",
type=str,
metavar="<text>",
help=(
"User description of the data (optional). "
"This doesn't affect any DVC operations."
),
)
import_parser.set_defaults(func=CmdImport)
+18 -12
View File
@@ -16,6 +16,7 @@ class CmdImportUrl(CmdBase):
out=self.args.out,
fname=self.args.file,
no_exec=self.args.no_exec,
desc=self.args.desc,
)
except DvcException:
logger.exception(
@@ -42,20 +43,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.",
@@ -71,4 +68,13 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Only create DVC-file without actually downloading it.",
)
import_parser.add_argument(
"--desc",
type=str,
metavar="<text>",
help=(
"User description of the data (optional). "
"This doesn't affect any DVC operations."
),
)
import_parser.set_defaults(func=CmdImportUrl)
+29 -1
View File
@@ -1,15 +1,42 @@
import argparse
import logging
import colorama
from dvc import analytics
from dvc.command.base import CmdBaseNoRepo, append_doc_link
from dvc.utils import boxify
from dvc.utils import format_link as fmt_link
logger = logging.getLogger(__name__)
def _welcome_message():
if analytics.is_enabled():
logger.info(
boxify(
"DVC has enabled anonymous aggregate usage analytics.\n"
"Read the analytics documentation (and how to opt-out) here:\n"
+ fmt_link("https://dvc.org/doc/user-guide/analytics"),
border_color="red",
)
)
msg = (
"{yellow}What's next?{nc}\n"
"{yellow}------------{nc}\n"
f"- Check out the documentation: {fmt_link('https://dvc.org/doc')}\n"
f"- Get help and share ideas: {fmt_link('https://dvc.org/chat')}\n"
f"- Star us on GitHub: {fmt_link('https://github.com/iterative/dvc')}"
).format(yellow=colorama.Fore.YELLOW, nc=colorama.Fore.RESET)
logger.info(msg)
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(
@@ -19,6 +46,7 @@ class CmdInit(CmdBaseNoRepo):
subdir=self.args.subdir,
)
self.config = self.repo.config
_welcome_message()
except InitError:
logger.exception("failed to initiate DVC")
return 1
+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
+30 -32
View File
@@ -11,34 +11,41 @@ logger = logging.getLogger(__name__)
DEFAULT_PRECISION = 5
def show_metrics(
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.
missing = metrics.pop(None, None)
lines = []
for branch, val in metrics.items():
if all_branches or all_tags or all_commits:
logger.info(f"{branch}:")
lines.append(f"{branch}:")
for fname, metric in val.items():
if not isinstance(metric, dict):
logger.info("\t{}: {}".format(fname, str(metric)))
lines.append("\t{}: {}".format(fname, str(metric)))
continue
logger.info(f"\t{fname}:")
for key, value in flatten(format_dict(metric), ".").items():
logger.info(f"\t\t{key}: {value}")
lines.append(f"\t{fname}:")
for key, value in flatten(format_dict(metric)).items():
lines.append(f"\t\t{key}: {value}")
if missing:
raise BadMetricError(missing)
return "\n".join(lines)
class CmdMetricsShow(CmdBase):
class CmdMetricsBase(CmdBase):
UNINITIALIZED = True
class CmdMetricsShow(CmdMetricsBase):
def run(self):
try:
metrics = self.repo.metrics.show(
@@ -54,20 +61,22 @@ class CmdMetricsShow(CmdBase):
logger.info(json.dumps(metrics))
else:
show_metrics(
table = _show_metrics(
metrics,
self.args.all_branches,
self.args.all_tags,
self.args.all_commits,
)
if table:
logger.info(table)
except DvcException:
logger.exception("failed to show metrics")
logger.exception("")
return 1
return 0
def _show_diff(diff, markdown=False, no_path=False, old=False, precision=None):
def _show_diff(diff, markdown=False, no_path=False, precision=None):
from collections import OrderedDict
from dvc.utils.diff import table
@@ -87,24 +96,20 @@ def _show_diff(diff, markdown=False, no_path=False, old=False, precision=None):
for metric, change in sorted_mdiff.items():
row = [] if no_path else [fname]
row.append(metric)
if old:
row.append(_round(change.get("old")))
row.append(_round(change.get("old")))
row.append(_round(change["new"]))
row.append(_round(change.get("diff", "diff not supported")))
row.append(_round(change.get("diff")))
rows.append(row)
header = [] if no_path else ["Path"]
header.append("Metric")
if old:
header.extend(["Old", "New"])
else:
header.append("Value")
header.extend(["Old", "New"])
header.append("Change")
return table(header, rows, markdown)
class CmdMetricsDiff(CmdBase):
class CmdMetricsDiff(CmdMetricsBase):
def run(self):
try:
diff = self.repo.metrics.diff(
@@ -124,7 +129,6 @@ class CmdMetricsDiff(CmdBase):
diff,
self.args.show_md,
self.args.no_path,
self.args.old,
precision=self.args.precision,
)
if table:
@@ -167,8 +171,8 @@ def add_parser(subparsers, parent_parser):
"targets",
nargs="*",
help=(
"Limit command scope to these metric files. Using -R, "
"directories to search metric files in can also be given."
"Limit command scope to these metrics files. Using -R, "
"directories to search metrics files in can also be given."
),
).complete = completion.FILE
metrics_show_parser.add_argument(
@@ -204,7 +208,7 @@ def add_parser(subparsers, parent_parser):
default=False,
help=(
"If any target is a directory, recursively search and process "
"metric files."
"metrics files."
),
)
metrics_show_parser.set_defaults(func=CmdMetricsShow)
@@ -232,8 +236,8 @@ def add_parser(subparsers, parent_parser):
"--targets",
nargs="*",
help=(
"Limit command scope to these metric files. Using -R, "
"directories to search metric files in can also be given."
"Limit command scope to these metrics files. Using -R, "
"directories to search metrics files in can also be given."
),
metavar="<paths>",
).complete = completion.FILE
@@ -244,7 +248,7 @@ def add_parser(subparsers, parent_parser):
default=False,
help=(
"If any target is a directory, recursively search and process "
"metric files."
"metrics files."
),
)
metrics_diff_parser.add_argument(
@@ -265,12 +269,6 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Show tabulated output in the Markdown format (GFM).",
)
metrics_diff_parser.add_argument(
"--old",
action="store_true",
default=False,
help="Show old metric value.",
)
metrics_diff_parser.add_argument(
"--no-path",
action="store_true",
+27 -8
View File
@@ -65,12 +65,11 @@ class CmdPlots(CmdBase):
html = PAGE_HTML.format(divs="\n".join(divs))
path = self.args.out or "plots.html"
path = os.path.join(os.getcwd(), path)
with open(path, "w") as fobj:
fobj.write(html)
logger.info(
"file://{}".format(os.path.join(self.repo.root_dir, path))
)
logger.info(f"file://{path}")
except DvcException:
logger.exception("")
@@ -80,13 +79,22 @@ class CmdPlots(CmdBase):
class CmdPlotsShow(CmdPlots):
UNINITIALIZED = True
def _func(self, *args, **kwargs):
return self.repo.plots.show(*args, **kwargs)
class CmdPlotsDiff(CmdPlots):
UNINITIALIZED = True
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):
@@ -117,7 +125,7 @@ def add_parser(subparsers, parent_parser):
fix_subparsers(plots_subparsers)
SHOW_HELP = "Generate plots from metric files."
SHOW_HELP = "Generate plots from metrics files."
plots_show_parser = plots_subparsers.add_parser(
"show",
parents=[parent_parser],
@@ -128,7 +136,9 @@ def add_parser(subparsers, parent_parser):
plots_show_parser.add_argument(
"targets",
nargs="*",
help="Plots files to visualize. Shows all plots by default.",
help="Files to visualize (supports any file, "
"even when not found as `plots` in `dvc.yaml`). "
"Shows all plots by default.",
).complete = completion.FILE
_add_props_arguments(plots_show_parser)
_add_output_arguments(plots_show_parser)
@@ -148,9 +158,18 @@ def add_parser(subparsers, parent_parser):
plots_diff_parser.add_argument(
"--targets",
nargs="*",
help="Plots file to visualize. Shows all plots by default.",
help="Files to visualize (supports any file, "
"even when not found as `plots` in `dvc.yaml`). "
"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",
)
@@ -158,7 +177,7 @@ def add_parser(subparsers, parent_parser):
_add_output_arguments(plots_diff_parser)
plots_diff_parser.set_defaults(func=CmdPlotsDiff)
PLOTS_MODIFY_HELP = "Modify display properties of plot metric files."
PLOTS_MODIFY_HELP = "Modify display properties of plot metrics files."
plots_modify_parser = plots_subparsers.add_parser(
"modify",
parents=[parent_parser],
+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."
)
+49 -34
View File
@@ -4,7 +4,7 @@ import os
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.command.metrics import show_metrics
from dvc.command.metrics import _show_metrics
from dvc.command.status import CmdDataStatus
from dvc.dvcfile import PIPELINE_FILE
from dvc.exceptions import DvcException
@@ -15,8 +15,7 @@ logger = logging.getLogger(__name__)
class CmdRepro(CmdBase):
def run(self):
saved_dir = os.path.realpath(os.curdir)
if self.args.cwd:
os.chdir(self.args.cwd)
os.chdir(self.args.cwd)
# Dirty hack so the for loop below can at least enter once
if self.args.all_pipelines:
@@ -27,28 +26,20 @@ class CmdRepro(CmdBase):
ret = 0
for target in self.args.targets:
try:
stages = self.repo.reproduce(
target,
single_item=self.args.single_item,
force=self.args.force,
dry=self.args.dry,
interactive=self.args.interactive,
pipeline=self.args.pipeline,
all_pipelines=self.args.all_pipelines,
run_cache=not self.args.no_run_cache,
no_commit=self.args.no_commit,
downstream=self.args.downstream,
recursive=self.args.recursive,
force_downstream=self.args.force_downstream,
experiment=self.args.experiment,
)
stages = self.repo.reproduce(target, **self._repro_kwargs)
if len(stages) == 0:
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
else:
logger.info(
"Use `dvc push` to send your updates to "
"remote storage."
)
if self.args.metrics:
metrics = self.repo.metrics.show()
show_metrics(metrics)
logger.info(_show_metrics(metrics))
except DvcException:
logger.exception("")
ret = 1
@@ -57,18 +48,25 @@ class CmdRepro(CmdBase):
os.chdir(saved_dir)
return ret
@property
def _repro_kwargs(self):
return {
"single_item": self.args.single_item,
"force": self.args.force,
"dry": self.args.dry,
"interactive": self.args.interactive,
"pipeline": self.args.pipeline,
"all_pipelines": self.args.all_pipelines,
"run_cache": not self.args.no_run_cache,
"no_commit": self.args.no_commit,
"downstream": self.args.downstream,
"recursive": self.args.recursive,
"force_downstream": self.args.force_downstream,
"pull": self.args.pull,
}
def add_parser(subparsers, parent_parser):
REPRO_HELP = (
"Reproduce complete or partial pipelines by executing their stages."
)
repro_parser = subparsers.add_parser(
"repro",
parents=[parent_parser],
description=append_doc_link(REPRO_HELP, "repro"),
help=REPRO_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
def add_arguments(repro_parser):
repro_parser.add_argument(
"targets",
nargs="*",
@@ -93,7 +91,8 @@ def add_parser(subparsers, parent_parser):
"-c",
"--cwd",
default=os.path.curdir,
help="Directory within your repo to reproduce from.",
help="Directory within your repo to reproduce from. Note: deprecated "
"by `dvc --cd <path>`.",
metavar="<path>",
)
repro_parser.add_argument(
@@ -168,10 +167,26 @@ def add_parser(subparsers, parent_parser):
help="Start from the specified stages when reproducing pipelines.",
)
repro_parser.add_argument(
"-e",
"--experiment",
"--pull",
action="store_true",
default=False,
help=argparse.SUPPRESS,
help=(
"Try automatically pulling missing cache for outputs restored "
"from the run-cache."
),
)
def add_parser(subparsers, parent_parser):
REPRO_HELP = (
"Reproduce complete or partial pipelines by executing their stages."
)
repro_parser = subparsers.add_parser(
"repro",
parents=[parent_parser],
description=append_doc_link(REPRO_HELP, "repro"),
help=REPRO_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
add_arguments(repro_parser)
repro_parser.set_defaults(func=CmdRepro)
+28 -7
View File
@@ -1,6 +1,7 @@
import argparse
import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException
@@ -20,6 +21,7 @@ class CmdRun(CmdBase):
self.args.plots_no_cache,
self.args.outs_persist,
self.args.outs_persist_no_cache,
self.args.checkpoints,
self.args.params,
self.args.command,
]
@@ -44,16 +46,18 @@ class CmdRun(CmdBase):
params=self.args.params,
fname=self.args.file,
wdir=self.args.wdir,
no_exec=self.args.no_exec,
no_exec=(self.args.no_exec or bool(self.args.checkpoints)),
force=self.args.force,
run_cache=not self.args.no_run_cache,
no_commit=self.args.no_commit,
outs_persist=self.args.outs_persist,
outs_persist_no_cache=self.args.outs_persist_no_cache,
checkpoints=self.args.checkpoints,
always_changed=self.args.always_changed,
name=self.args.name,
single_stage=self.args.single_stage,
external=self.args.external,
desc=self.args.desc,
)
except DvcException:
logger.exception("")
@@ -99,7 +103,7 @@ def add_parser(subparsers, parent_parser):
default=[],
help="Declare dependencies for reproducible cmd.",
metavar="<path>",
)
).complete = completion.FILE
run_parser.add_argument(
"-n", "--name", help="Stage name.",
)
@@ -110,7 +114,7 @@ def add_parser(subparsers, parent_parser):
default=[],
help="Declare output file or directory.",
metavar="<filename>",
)
).complete = completion.FILE
run_parser.add_argument(
"-O",
"--outs-no-cache",
@@ -119,7 +123,7 @@ def add_parser(subparsers, parent_parser):
help="Declare output file or directory "
"(do not put into DVC cache).",
metavar="<filename>",
)
).complete = completion.FILE
run_parser.add_argument(
"-p",
"--params",
@@ -127,13 +131,13 @@ def add_parser(subparsers, parent_parser):
default=[],
help="Declare parameter to use as additional dependency.",
metavar="[<filename>:]<params_list>",
)
).complete = completion.FILE
run_parser.add_argument(
"-m",
"--metrics",
action="append",
default=[],
help="Declare output metric file.",
help="Declare output metrics file.",
metavar="<path>",
)
run_parser.add_argument(
@@ -141,7 +145,7 @@ def add_parser(subparsers, parent_parser):
"--metrics-no-cache",
action="append",
default=[],
help="Declare output metric file (do not put into DVC cache).",
help="Declare output metrics file (do not put into DVC cache).",
metavar="<path>",
)
run_parser.add_argument(
@@ -211,6 +215,14 @@ def add_parser(subparsers, parent_parser):
"removed upon repro (do not put into DVC cache).",
metavar="<filename>",
)
run_parser.add_argument(
"-c",
"--checkpoints",
action="append",
default=[],
help=argparse.SUPPRESS,
metavar="<filename>",
).complete = completion.FILE
run_parser.add_argument(
"--always-changed",
action="store_true",
@@ -229,6 +241,15 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Allow outputs that are outside of the DVC repository.",
)
run_parser.add_argument(
"--desc",
type=str,
metavar="<text>",
help=(
"User description of the stage (optional). "
"This doesn't affect any DVC operations."
),
)
run_parser.add_argument(
"command", nargs=argparse.REMAINDER, help="Command to execute."
)
+16 -2
View File
@@ -1,6 +1,8 @@
import logging
from dvc.command.data_sync import CmdDataBase
from dvc.exceptions import DvcException
from dvc.utils import format_link
logger = logging.getLogger(__name__)
@@ -9,6 +11,11 @@ class CmdDataStatus(CmdDataBase):
STATUS_LEN = 20
STATUS_INDENT = "\t"
UP_TO_DATE_MSG = "Data and pipelines are up to date."
IN_SYNC_MSG = "Cache and remote '{remote}' are in sync."
EMPTY_PROJECT_MSG = (
"There are no data or pipelines tracked in this project yet.\n"
"See {link} to get started!"
).format(link=format_link("https://dvc.org/doc/start"))
def _normalize(self, s):
s += ":"
@@ -60,10 +67,17 @@ class CmdDataStatus(CmdDataBase):
logger.info(json.dumps(st))
elif st:
self._show(st, indent)
elif not self.repo.stages:
logger.info(self.EMPTY_PROJECT_MSG)
elif self.args.cloud or self.args.remote:
remote = self.args.remote or self.repo.config["core"].get(
"remote"
)
logger.info(self.IN_SYNC_MSG.format(remote=remote))
else:
logger.info(self.UP_TO_DATE_MSG)
except Exception: # noqa, pylint: disable=broad-except
logger.exception("failed to obtain data status")
except DvcException:
logger.exception("")
return 1
return 0
+4 -144
View File
@@ -1,159 +1,18 @@
import argparse
import itertools
import logging
import os
import pathlib
import platform
import uuid
from dvc.command.base import CmdBaseNoRepo, append_doc_link
from dvc.exceptions import DvcException, NotDvcRepoError
from dvc.scm.base import SCMError
from dvc.system import System
from dvc.utils import relpath
from dvc.utils.pkg import PKG
from dvc.version import __version__
try:
import psutil
except ImportError:
psutil = None
from dvc.info import get_dvc_info
logger = logging.getLogger(__name__)
class CmdVersion(CmdBaseNoRepo):
def run(self):
from dvc.repo import Repo
package = PKG
if PKG is None:
package = ""
else:
package = f"({PKG})"
info = [
f"DVC version: {__version__} {package}",
"---------------------------------",
f"Platform: Python {platform.python_version()} on "
f"{platform.platform()}",
f"Supports: {self.get_supported_remotes()}",
]
try:
repo = Repo()
root_directory = repo.root_dir
# cache_dir might not exist yet (e.g. after `dvc init`), and we
# can't auto-create it, as it might cause issues if the user
# later decides to enable shared cache mode with
# `dvc config cache.shared group`.
if os.path.exists(repo.cache.local.cache_dir):
info.append(
"Cache types: {}".format(
self.get_linktype_support_info(repo)
)
)
if psutil:
fs_type = self.get_fs_type(repo.cache.local.cache_dir)
info.append(f"Cache directory: {fs_type}")
else:
logger.warning(
"Unable to detect supported link types, as cache "
"directory '{}' doesn't exist. It is usually auto-created "
"by commands such as `dvc add/fetch/pull/run/import`, "
"but you could create it manually to enable this "
"check.".format(relpath(repo.cache.local.cache_dir))
)
except NotDvcRepoError:
root_directory = os.getcwd()
except SCMError:
root_directory = os.getcwd()
info.append("Repo: dvc, git (broken)")
else:
if psutil:
fs_root = self.get_fs_type(os.path.abspath(root_directory))
info.append(f"Workspace directory: {fs_root}")
info.append("Repo: {}".format(_get_dvc_repo_info(repo)))
logger.info("\n".join(info))
dvc_info = get_dvc_info()
logger.info(dvc_info)
return 0
@staticmethod
def get_fs_type(path):
partition = {
pathlib.Path(part.mountpoint): (part.fstype + " on " + part.device)
for part in psutil.disk_partitions(all=True)
}
path = pathlib.Path(path)
for parent in itertools.chain([path], path.parents):
if parent in partition:
return partition[parent]
return ("unknown", "none")
@staticmethod
def get_linktype_support_info(repo):
links = {
"reflink": (System.reflink, None),
"hardlink": (System.hardlink, System.is_hardlink),
"symlink": (System.symlink, System.is_symlink),
}
fname = "." + str(uuid.uuid4())
src = os.path.join(repo.cache.local.cache_dir, fname)
open(src, "w").close()
dst = os.path.join(repo.root_dir, fname)
cache = []
for name, (link, is_link) in links.items():
try:
link(src, dst)
status = "supported"
if is_link and not is_link(dst):
status = "broken"
os.unlink(dst)
except DvcException:
status = "not supported"
if status == "supported":
cache.append(name)
os.remove(src)
return ", ".join(cache)
@staticmethod
def get_supported_remotes():
from dvc.tree import TREES
supported_remotes = []
for tree_cls in TREES:
if not tree_cls.get_missing_deps():
supported_remotes.append(tree_cls.scheme)
if len(supported_remotes) == len(TREES):
return "All remotes"
if len(supported_remotes) == 1:
return supported_remotes
return ", ".join(supported_remotes)
def _get_dvc_repo_info(repo):
if repo.config.get("core", {}).get("no_scm", False):
return "dvc (no_scm)"
if repo.root_dir != repo.scm.root_dir:
return "dvc (subdir), git"
return "dvc, git"
def add_parser(subparsers, parent_parser):
VERSION_HELP = (
@@ -165,5 +24,6 @@ def add_parser(subparsers, parent_parser):
description=append_doc_link(VERSION_HELP, "version"),
help=VERSION_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
aliases=["doctor"],
)
version_parser.set_defaults(func=CmdVersion)
+60 -11
View File
@@ -103,6 +103,7 @@ class RelPath(str):
REMOTE_COMMON = {
"url": str,
"checksum_jobs": All(Coerce(int), Range(1)),
"jobs": All(Coerce(int), Range(1)),
Optional("no_traverse"): Bool, # obsoleted
"verify": Bool,
}
@@ -118,7 +119,19 @@ HTTP_COMMON = {
"user": str,
"password": str,
"ask_password": Bool,
"ssl_verify": Bool,
"method": str,
}
WEBDAV_COMMON = {
"user": str,
"password": str,
"ask_password": Bool,
"token": str,
"cert_path": str,
"key_path": str,
"timeout": Coerce(int),
}
SCHEMA = {
"core": {
"remote": Lower,
@@ -127,13 +140,16 @@ SCHEMA = {
Optional("analytics", default=True): Bool,
Optional("hardlink_lock", default=False): Bool,
Optional("no_scm", default=False): Bool,
Optional("autostage", default=False): Bool,
Optional("experiments", default=False): Bool,
Optional("check_update", default=True): Bool,
},
"cache": {
"local": str,
"s3": str,
"gs": str,
"hdfs": str,
"webhdfs": str,
"ssh": str,
"azure": str,
# This is for default local cache
@@ -151,7 +167,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,
@@ -176,9 +192,17 @@ SCHEMA = {
"keyfile": str,
"timeout": Coerce(int),
"gss_auth": Bool,
"allow_agent": Bool,
**REMOTE_COMMON,
},
"hdfs": {"user": str, **REMOTE_COMMON},
"webhdfs": {
"hdfscli_config": str,
"webhdfs_token": str,
"user": str,
"webhdfs_alias": str,
**REMOTE_COMMON,
},
"azure": {"connection_string": str, **REMOTE_COMMON},
"oss": {
"oss_key_id": str,
@@ -199,6 +223,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
}
)
@@ -207,6 +233,8 @@ SCHEMA = {
"row_limit": All(Coerce(int), Range(1)),
"row_cleanup_quota": All(Coerce(int), Range(0, 100)),
},
# section for experimental features
"feature": {Optional("parametrization", default=False): Bool},
}
COMPILED_SCHEMA = Schema(SCHEMA)
@@ -258,7 +286,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 +340,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 +358,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
@@ -351,6 +386,12 @@ class Config(dict):
def resolve(path):
if os.path.isabs(path) or re.match(r"\w+://", path):
return path
# on windows convert slashes to backslashes
# to have path compatible with abs_conf_dir
if os.path.sep == "\\" and "/" in path:
path = path.replace("/", "\\")
return RelPath(os.path.join(abs_conf_dir, path))
return Config._map_dirs(conf, resolve)
@@ -377,7 +418,15 @@ class Config(dict):
dirs_schema = {
"cache": {"dir": func},
"remote": {
str: {"url": func, "gdrive_user_credentials_file": func}
str: {
"url": func,
"gdrive_user_credentials_file": func,
"gdrive_service_account_p12_file_path": func,
"credentialpath": func,
"keyfile": func,
"cert_path": func,
"key_path": func,
}
},
}
return Schema(dirs_schema, extra=ALLOW_EXTRA)(conf)
@@ -388,7 +437,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 +451,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 +490,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
+30 -22
View File
@@ -62,8 +62,11 @@ class DataCloud:
"""
remote = self.get_remote(remote, "push")
return self.repo.cache.local.push(
cache, jobs=jobs, remote=remote, show_checksums=show_checksums,
return remote.push(
self.repo.cache.local,
cache,
jobs=jobs,
show_checksums=show_checksums,
)
def pull(
@@ -81,26 +84,21 @@ class DataCloud:
"""
remote = self.get_remote(remote, "pull")
downloaded_items_num = self.repo.cache.local.pull(
cache, jobs=jobs, remote=remote, show_checksums=show_checksums
return remote.pull(
self.repo.cache.local,
cache,
jobs=jobs,
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 +109,18 @@ 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
return remote.status(
self.repo.cache.local,
cache,
jobs=jobs,
show_checksums=show_checksums,
log_missing=log_missing,
)
def get_url_for(self, remote, checksum):
remote = self.get_remote(remote)
return str(remote.tree.hash_to_path_info(checksum))
+11 -1
View File
@@ -11,6 +11,9 @@ from dvc.dependency.local import LocalDependency
from dvc.dependency.param import ParamsDependency
from dvc.dependency.s3 import S3Dependency
from dvc.dependency.ssh import SSHDependency
from dvc.dependency.webdav import WebDAVDependency
from dvc.dependency.webdavs import WebDAVSDependency
from dvc.dependency.webhdfs import WebHDFSDependency
from dvc.output.base import BaseOutput
from dvc.scheme import Schemes
@@ -25,6 +28,9 @@ DEPS = [
HTTPSDependency,
S3Dependency,
SSHDependency,
WebDAVDependency,
WebDAVSDependency,
WebHDFSDependency,
# NOTE: LocalDependency is the default choice
]
@@ -37,16 +43,20 @@ DEP_MAP = {
Schemes.HDFS: HDFSDependency,
Schemes.HTTP: HTTPDependency,
Schemes.HTTPS: HTTPSDependency,
Schemes.WEBDAV: WebDAVDependency,
Schemes.WEBDAVS: WebDAVSDependency,
Schemes.WEBHDFS: WebHDFSDependency,
}
# NOTE: schema for dependencies is basically the same as for outputs, but
# without output-specific entries like 'cache' (whether or not output is
# cached, see -o and -O flags for `dvc run`) and 'metric' (whether or not
# output is a metric file and how to parse it, see `-M` flag for `dvc run`).
# output is a metrics file and how to parse it, see `-M` flag for `dvc run`).
SCHEMA = output.SCHEMA.copy()
del SCHEMA[BaseOutput.PARAM_CACHE]
del SCHEMA[BaseOutput.PARAM_METRIC]
del SCHEMA[BaseOutput.PARAM_DESC]
SCHEMA.update(RepoDependency.REPO_SCHEMA)
SCHEMA.update(ParamsDependency.PARAM_SCHEMA)
+7 -3
View File
@@ -1,3 +1,5 @@
from typing import Type
from dvc.exceptions import DvcException
@@ -21,9 +23,11 @@ class DependencyIsStageFileError(DvcException):
class BaseDependency:
IS_DEPENDENCY = True
DoesNotExistError = DependencyDoesNotExistError
IsNotFileOrDirError = DependencyIsNotFileOrDirError
IsStageFileError = DependencyIsStageFileError
DoesNotExistError = DependencyDoesNotExistError # type: Type[DvcException]
IsNotFileOrDirError = (
DependencyIsNotFileOrDirError
) # type: Type[DvcException]
IsStageFileError = DependencyIsStageFileError # type: Type[DvcException]
def update(self, rev=None):
pass
+33 -34
View File
@@ -2,12 +2,12 @@ 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.hash_info import HashInfo
from dvc.utils.serialize import LOADERS, ParseError
class MissingParamsError(DvcException):
@@ -22,8 +22,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 = {}
@@ -34,7 +32,7 @@ class ParamsDependency(LocalDependency):
else:
assert isinstance(params, dict)
self.params = list(params.keys())
info = params
info = {self.PARAM_PARAMS: params}
super().__init__(
stage,
@@ -43,60 +41,61 @@ class ParamsDependency(LocalDependency):
info=info,
)
def dumpd(self):
ret = super().dumpd()
if not self.hash_info:
ret[self.PARAM_PARAMS] = self.params
return ret
def fill_values(self, values=None):
"""Load params values dynamically."""
if not values:
return
info = {}
for param in self.params:
if param in values:
self.info[param] = values[param]
info[param] = values[param]
self.hash_info = HashInfo(self.PARAM_PARAMS, info)
def save(self):
super().save()
self.info = self.save_info()
def workspace_status(self):
status = super().workspace_status()
def status(self):
status = super().status()
if status[str(self)] == "deleted":
if status.get(str(self)) == "deleted":
return status
status = defaultdict(dict)
info = self.read_params()
info = self.hash_info.value if self.hash_info else {}
actual = self.read_params()
for param in self.params:
if param not in info.keys():
if param not in actual.keys():
st = "deleted"
elif param not in self.info:
elif param not in info:
st = "new"
elif info[param] != self.info[param]:
elif actual[param] != info[param]:
st = "modified"
else:
assert info[param] == self.info[param]
assert actual[param] == info[param]
continue
status[str(self)][param] = st
return status
def dumpd(self):
return {
self.PARAM_PATH: self.def_path,
self.PARAM_PARAMS: self.info or self.params,
}
def status(self):
return self.workspace_status()
def read_params(self):
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:
@@ -106,7 +105,7 @@ class ParamsDependency(LocalDependency):
pass
return ret
def save_info(self):
def get_hash(self):
info = self.read_params()
missing_params = set(self.params) - set(info.keys())
@@ -117,4 +116,4 @@ class ParamsDependency(LocalDependency):
)
)
return info
return HashInfo(self.PARAM_PARAMS, info)
+20 -33
View File
@@ -1,10 +1,5 @@
import os
from voluptuous import Required
from dvc.exceptions import OutputNotFoundError
from dvc.path_info import PathInfo
from .local import LocalDependency
@@ -42,40 +37,31 @@ class RepoDependency(LocalDependency):
def __str__(self):
return "{} ({})".format(self.def_path, self.def_repo[self.PARAM_URL])
def _make_repo(self, *, locked=True):
def _make_repo(self, *, locked=True, **kwargs):
from dvc.external_repo import external_repo
d = self.def_repo
rev = (d.get("rev_lock") if locked else None) or d.get("rev")
return external_repo(d["url"], rev=rev)
return external_repo(d["url"], rev=rev, **kwargs)
def _get_checksum(self, locked=True):
from dvc.repo.tree import RepoTree
def _get_hash(self, locked=True):
# we want stream but not fetch, so DVC out directories are
# walked, but dir contents is not fetched
with self._make_repo(locked=locked, fetch=False, stream=True) as repo:
return repo.get_checksum(self.def_path)
with self._make_repo(locked=locked) as repo:
try:
return repo.find_out_by_relpath(self.def_path).info["md5"]
except OutputNotFoundError:
path = PathInfo(os.path.join(repo.root_dir, self.def_path))
def workspace_status(self):
current = self._get_hash(locked=True)
updated = self._get_hash(locked=False)
# we want stream but not fetch, so DVC out directories are
# walked, but dir contents is not fetched
tree = RepoTree(repo, stream=True)
# 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 tree.get_file_hash(path)
def status(self):
current_checksum = self._get_checksum(locked=True)
updated_checksum = self._get_checksum(locked=False)
if current_checksum != updated_checksum:
if current != updated:
return {str(self): "update available"}
return {}
def status(self):
return self.workspace_status()
def save(self):
pass
@@ -83,14 +69,15 @@ class RepoDependency(LocalDependency):
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
def download(self, to):
with self._make_repo() as repo:
cache = self.repo.cache.local
with self._make_repo(cache_dir=cache.cache_dir) as repo:
if self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
cache = self.repo.cache.local
with repo.use_cache(cache):
_, _, cache_infos = repo.fetch_external([self.def_path])
cache.checkout(to.path_info, cache_infos[0])
_, _, cache_infos = repo.fetch_external([self.def_path])
cache.checkout(to.path_info, cache_infos[0])
def update(self, rev=None):
if rev:
+8
View File
@@ -0,0 +1,8 @@
from dvc.dependency.base import BaseDependency
from dvc.output.base import BaseOutput
from ..tree.webdav import WebDAVTree
class WebDAVDependency(BaseDependency, BaseOutput):
TREE_CLS = WebDAVTree
+6
View File
@@ -0,0 +1,6 @@
from ..tree.webdavs import WebDAVSTree
from .webdav import WebDAVDependency
class WebDAVSDependency(WebDAVDependency):
TREE_CLS = WebDAVSTree
+6
View File
@@ -0,0 +1,6 @@
from dvc.dependency.base import BaseDependency
from dvc.output.webhdfs import WebHDFSOutput
class WebHDFSDependency(BaseDependency, WebHDFSOutput):
pass
+102
View File
@@ -0,0 +1,102 @@
import posixpath
from operator import itemgetter
from pygtrie import Trie
from .hash_info import HashInfo
def _diff(ancestor, other, allow_removed=False):
from dictdiffer import diff
from dvc.exceptions import MergeError
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(ancestor, our, their):
import copy
from dictdiffer import patch
our_diff = _diff(ancestor, our)
if not our_diff:
return copy.deepcopy(their)
their_diff = _diff(ancestor, their)
if not their_diff:
return copy.deepcopy(our)
# make sure there are no conflicting files
_diff(our, their, allow_removed=True)
return patch(our_diff + their_diff, ancestor)
class DirInfo:
PARAM_RELPATH = "relpath"
def __init__(self):
self.trie = Trie()
@property
def size(self):
try:
return sum(
hash_info.size
for _, hash_info in self.trie.iteritems() # noqa: B301
)
except TypeError:
return None
@property
def nfiles(self):
return len(self.trie)
def items(self, path_info=None):
for key, hash_info in self.trie.iteritems(): # noqa: B301
path = posixpath.sep.join(key)
if path_info is not None:
path = path_info / path
yield path, hash_info
@classmethod
def from_list(cls, lst):
ret = DirInfo()
for _entry in lst:
entry = _entry.copy()
relpath = entry.pop(cls.PARAM_RELPATH)
parts = tuple(relpath.split(posixpath.sep))
ret.trie[parts] = HashInfo.from_dict(entry)
return ret
def to_list(self):
# Sorting the list by path to ensure reproducibility
return sorted(
(
{
# NOTE: not using hash_info.to_dict() because we don't want
# size/nfiles fields at this point.
hash_info.name: hash_info.value,
self.PARAM_RELPATH: posixpath.sep.join(parts),
}
for parts, hash_info in self.trie.iteritems() # noqa: B301
),
key=itemgetter(self.PARAM_RELPATH),
)
def merge(self, ancestor, their):
merged = DirInfo()
merged.trie = _merge(ancestor.trie, self.trie, their.trie)
return merged
+75 -40
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__)
@@ -30,6 +36,10 @@ class LockfileCorruptedError(DvcException):
pass
class ParametrizedDumpError(DvcException):
pass
def is_valid_filename(path):
return path.endswith(DVC_FILE_SUFFIX) or os.path.basename(path) in [
DVC_FILE,
@@ -39,10 +49,14 @@ def is_valid_filename(path):
def is_dvc_file(path):
return os.path.isfile(path) and (
is_valid_filename(path) or os.path.basename(path) == PIPELINE_LOCK
is_valid_filename(path) or is_lock_file(path)
)
def is_lock_file(path):
return os.path.basename(path) == PIPELINE_LOCK
def check_dvc_filename(path):
if not is_valid_filename(path):
raise StageFileBadNameError(
@@ -56,9 +70,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 +105,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 +131,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 +153,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 +164,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."""
@@ -155,48 +183,52 @@ class PipelineFile(FileMixin):
return Lockfile(self.repo, os.path.splitext(self.path)[0] + ".lock")
def dump(
self, stage, update_pipeline=False, no_lock=False, **kwargs
self, stage, update_pipeline=True, update_lock=True, **kwargs
): # pylint: disable=arguments-differ
"""Dumps given stage appropriately in the dvcfile."""
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)
if not no_lock:
if update_lock:
self._dump_lockfile(stage)
def _dump_lockfile(self, stage):
self._lockfile.dump(stage)
@staticmethod
def _check_if_parametrized(stage):
if stage.raw_data.parametrized:
raise ParametrizedDumpError(f"cannot dump a parametrized {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", {})
self._check_if_parametrized(stage)
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
@@ -209,7 +241,7 @@ class PipelineFile(FileMixin):
def stages(self):
data, _ = self._load()
lockfile_data = self._lockfile.load()
return StageLoader(self, data.get("stages", {}), lockfile_data)
return StageLoader(self, data, lockfile_data)
def remove(self, force=False):
if not force:
@@ -239,6 +271,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 +281,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 +293,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 +327,9 @@ class Lockfile(FileMixin):
else:
self.remove()
def merge(self, ancestor, other):
raise NotImplementedError
class Dvcfile:
def __new__(cls, repo, path, **kwargs):
+2
View File
@@ -1,2 +1,4 @@
DVC_CHECKPOINT = "DVC_CHECKPOINT"
DVC_DAEMON = "DVC_DAEMON"
DVC_PAGER = "DVC_PAGER"
DVC_ROOT = "DVC_ROOT"
+44 -21
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):
@@ -42,7 +42,7 @@ class OutputDuplicationError(DvcException):
class OutputNotFoundError(DvcException):
"""Thrown if a file/directory not found in repository pipelines.
"""Thrown if a file/directory is not found as an output in any pipeline.
Args:
output (unicode): path to the file/directory.
@@ -105,7 +105,7 @@ class ArgumentDuplicationError(DvcException):
class MoveNotDataSourceError(DvcException):
"""Thrown if attempted to move a file/directory that is not an output
"""Thrown when trying to move a file/directory that is not an output
in a data source stage.
Args:
@@ -164,33 +164,30 @@ class BadMetricError(DvcException):
def __init__(self, paths):
super().__init__(
"the following metrics do not exist, "
"are not metric files or are malformed: {paths}".format(
"are not metrics files or are malformed: {paths}".format(
paths=", ".join(f"'{path}'" for path in paths)
)
)
class NoMetricsError(DvcException):
def __init__(self):
pass
class NoMetricsParsedError(NoMetricsError):
def __init__(self, command):
super().__init__(
"no metric files in this repository. Use `-m/-M` options for "
"`dvc run` to mark stage outputs as metrics."
f"Could not parse {command} files. Use `-v` option to see more "
"details."
)
class NoPlotsError(DvcException):
def __init__(self):
class NoMetricsFoundError(NoMetricsError):
def __init__(self, command, run_options):
super().__init__(
"no plots in this repository. Use `--plots/--plots-no-cache` "
"options for `dvc run` to mark stage outputs as plots."
)
class YAMLFileCorruptedError(DvcException):
def __init__(self, path):
path = relpath(path)
super().__init__(
f"unable to read: '{path}', YAML file structure is corrupted"
f"No {command} files in this repository. "
f"Use `{run_options}` options for "
f"`dvc run` to mark stage outputs as {command}."
)
@@ -230,6 +227,11 @@ class FileMissingError(DvcException):
)
class FileOwnershipError(DvcException):
def __init__(self, path):
super().__init__(f"file '{path}' not owned by user! ")
class DvcIgnoreInCollectedDirError(DvcException):
def __init__(self, ignore_dirname):
super().__init__(
@@ -268,8 +270,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 +348,22 @@ class NoOutputOrStageError(DvcException):
f"'{target}' "
f"does not exist as an output or a stage name in '{file}'"
)
class MergeError(DvcException):
pass
class CacheLinkError(DvcException):
SUPPORT_LINK = "See {} for more information.".format(
format_link(
"https://dvc.org/doc/user-guide/troubleshooting#cache-types"
)
)
def __init__(self, path_infos):
msg = "No possible cache link types for '{}'. {}".format(
", ".join([str(path) for path in path_infos]), self.SUPPORT_LINK,
)
super().__init__(msg)
self.path_infos = path_infos
+184 -136
View File
@@ -4,12 +4,14 @@ import tempfile
import threading
from contextlib import contextmanager
from distutils.dir_util import copy_tree
from typing import Iterable
from typing import Dict, Iterable
from funcy import cached_property, retry, wrap_with
from funcy import cached_property, reraise, retry, wrap_with
from dvc.cache import Cache
from dvc.config import NoRemoteError, NotDvcRepoError
from dvc.exceptions import (
DvcException,
FileMissingError,
NoOutputInExternalRepoError,
NoRemoteInExternalRepoError,
@@ -18,47 +20,61 @@ 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__)
class IsADVCRepoError(DvcException):
"""Raised when it is not expected to be a dvc repo."""
@contextmanager
def external_repo(url, rev=None, for_write=False):
def external_repo(url, rev=None, for_write=False, **kwargs):
logger.debug("Creating external repo %s@%s", url, rev)
path = _cached_clone(url, rev, for_write=for_write)
if not rev:
# Local HEAD points to the tip of whatever branch we first cloned from
# (which may not be the default branch), use origin/HEAD here to get
# the tip of the default branch
rev = "refs/remotes/origin/HEAD"
try:
repo = ExternalRepo(path, url, rev, for_write=for_write)
except NotDvcRepoError:
repo = ExternalGitRepo(path, url, rev)
# Local HEAD points to the tip of whatever branch we first cloned from
# (which may not be the default branch), use origin/HEAD here to get
# the tip of the default branch
rev = rev or "refs/remotes/origin/HEAD"
root_dir = path if for_write else os.path.realpath(path)
conf = dict(
root_dir=root_dir,
url=url,
scm=None if for_write else Git(root_dir),
rev=None if for_write else rev,
for_write=for_write,
uninitialized=True,
**kwargs,
)
repo = ExternalRepo(**conf)
try:
yield repo
except NoRemoteError:
raise NoRemoteInExternalRepoError(url)
except NoRemoteError as exc:
raise NoRemoteInExternalRepoError(url) from exc
except OutputNotFoundError as exc:
if exc.repo is repo:
raise NoOutputInExternalRepoError(exc.output, repo.root_dir, url)
raise NoOutputInExternalRepoError(
exc.output, repo.root_dir, url
) from exc
raise
except FileMissingError as exc:
raise PathMissingError(exc.path, url)
raise PathMissingError(exc.path, url) from exc
finally:
repo.close()
if for_write:
_remove(path)
CLONES = {}
CACHE_DIRS = {}
CLONES: Dict[str, str] = {}
CACHE_DIRS: Dict[str, str] = {}
def clean_repos():
@@ -71,45 +87,67 @@ def clean_repos():
_remove(path)
class BaseExternalRepo:
class ExternalRepo(Repo):
# pylint: disable=no-member
_local_cache = None
def __init__(
self,
root_dir,
url,
scm=None,
rev=None,
for_write=False,
cache_dir=None,
cache_types=None,
uninitialized=False,
**kwargs,
):
super().__init__(
root_dir, scm=scm, rev=rev, uninitialized=uninitialized
)
self.url = url
self.for_write = for_write
self.cache_dir = cache_dir or self._get_cache_dir()
self.cache_types = cache_types
self._setup_cache(self)
self._fix_upstream(self)
self.tree_confs = kwargs
def __str__(self):
return self.url
@property
def local_cache(self):
if hasattr(self, "cache"):
return self.cache.local
return self._local_cache
@contextmanager
def use_cache(self, cache):
"""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
self._local_cache = cache
yield
if hasattr(self, "cache"):
self.cache.local = save_cache
self._local_cache = None
@cached_property
def repo_tree(self):
return RepoTree(self, fetch=True)
return self._get_tree_for(
self, subrepos=not self.for_write, repo_factory=self.make_repo
)
def get_rev(self):
assert self.scm
if isinstance(self.tree, LocalTree):
return self.scm.get_rev()
return self.tree.rev
def _fetch_to_cache(self, path_info, repo, callback, **kwargs):
# don't support subrepo traversal as it might fail due to difference
# in remotes
tree = self._get_tree_for(repo)
cache = repo.cache.local
hash_info = tree.get_hash(
path_info, download_callback=callback, **kwargs
)
cache.save(
path_info,
tree,
hash_info,
save_link=False,
download_callback=callback,
)
return hash_info
def fetch_external(self, paths: Iterable, **kwargs):
"""Fetch specified external repo paths into cache.
@@ -120,130 +158,135 @@ class BaseExternalRepo:
"""
download_results = []
failed = 0
root = PathInfo(self.root_dir)
paths = [PathInfo(self.root_dir) / path for path in paths]
paths = [root / path for path in paths]
def download_update(result):
download_results.append(result)
save_infos = []
hash_infos = []
for path in paths:
if not self.repo_tree.exists(path):
raise PathMissingError(path, self.url)
save_info = self.local_cache.save(
path,
self.repo_tree,
None,
save_link=False,
download_callback=download_update,
)
save_infos.append(save_info)
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
metadata = self.repo_tree.metadata(path)
return sum(download_results), failed, save_infos
self._check_repo(path, metadata.repo)
repo = metadata.repo
hash_info = self._fetch_to_cache(
path, repo, download_update, **kwargs
)
hash_infos.append(hash_info)
return sum(download_results), failed, hash_infos
def _check_repo(self, path_info, repo):
if not repo:
return
repo_path = PathInfo(repo.root_dir)
if path_info == repo_path and isinstance(repo, Repo):
message = "Cannot fetch a complete DVC repository"
if repo.root_dir != self.root_dir:
rel = relpath(repo.root_dir, self.root_dir)
message += f" '{rel}'"
raise IsADVCRepoError(message)
def get_external(self, path, dest):
"""Convenience wrapper for fetch_external and checkout."""
if self.local_cache:
path_info = PathInfo(self.root_dir) / path
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
metadata = self.repo_tree.metadata(path_info)
self._check_repo(path_info, metadata.repo)
if metadata.output_exists:
repo = metadata.repo
cache = repo.cache.local
# fetch DVC and git files to tmpdir cache, then checkout
_, _, save_infos = self.fetch_external([path])
self.local_cache.checkout(PathInfo(dest), save_infos[0])
save_info = self._fetch_to_cache(path_info, repo, None)
cache.checkout(PathInfo(dest), save_info)
else:
# git-only erepo with no cache, just copy files directly
# to dest
path = PathInfo(self.root_dir) / path
if not self.repo_tree.exists(path):
raise PathMissingError(path, self.url)
self.repo_tree.copytree(path, dest)
# git-only folder, just copy files directly to dest
tree = self._get_tree_for(metadata.repo) # ignore subrepos
tree.copytree(path_info, dest)
def _get_tree_for(self, repo, **kwargs):
"""
Provides a combined tree of a single repo with dvc + git/local tree.
"""
kw = {**self.tree_confs, **kwargs}
if "fetch" not in kw:
kw["fetch"] = True
return RepoTree(repo, **kw)
class ExternalRepo(Repo, BaseExternalRepo):
def __init__(self, root_dir, url, rev, for_write=False):
if for_write:
super().__init__(root_dir)
else:
root_dir = os.path.realpath(root_dir)
super().__init__(root_dir, scm=Git(root_dir), rev=rev)
self.url = url
self._set_cache_dir()
self._fix_upstream()
def get_checksum(self, path):
path_info = PathInfo(self.root_dir) / path
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
metadata = self.repo_tree.metadata(path_info)
@wrap_with(threading.Lock())
def _set_cache_dir(self):
try:
cache_dir = CACHE_DIRS[self.url]
except KeyError:
cache_dir = CACHE_DIRS[self.url] = tempfile.mkdtemp("dvc-cache")
# skip subrepos to check for
tree = self._get_tree_for(metadata.repo)
return tree.get_hash(path_info)
self.cache.local.cache_dir = cache_dir
self._local_cache = self.cache.local
def _fix_upstream(self):
if not os.path.isdir(self.url):
return
try:
src_repo = Repo(self.url)
except NotDvcRepoError:
# If ExternalRepo does not throw NotDvcRepoError and Repo does,
# the self.url might be a bare git repo.
# NOTE: This will fail to resolve remote with relative path,
# same as if it was a remote DVC repo.
return
try:
remote_name = self.config["core"].get("remote")
if remote_name:
self._fix_local_remote(src_repo, remote_name)
else:
self._add_upstream(src_repo)
finally:
src_repo.close()
def _fix_local_remote(self, src_repo, remote_name):
@staticmethod
def _fix_local_remote(orig_repo, src_repo, remote_name):
# If a remote URL is relative to the source repo,
# it will have changed upon config load and made
# relative to this new repo. Restore the old one here.
new_remote = self.config["remote"][remote_name]
new_remote = orig_repo.config["remote"][remote_name]
old_remote = src_repo.config["remote"][remote_name]
if new_remote["url"] != old_remote["url"]:
new_remote["url"] = old_remote["url"]
def _add_upstream(self, src_repo):
@staticmethod
def _add_upstream(orig_repo, src_repo):
# Fill the empty upstream entry with a new remote pointing to the
# original repo's cache location.
cache_dir = src_repo.cache.local.cache_dir
self.config["remote"]["auto-generated-upstream"] = {"url": cache_dir}
self.config["core"]["remote"] = "auto-generated-upstream"
orig_repo.config["remote"]["auto-generated-upstream"] = {
"url": cache_dir
}
orig_repo.config["core"]["remote"] = "auto-generated-upstream"
def make_repo(self, path):
repo = Repo(path, scm=self.scm, rev=self.get_rev())
class ExternalGitRepo(BaseExternalRepo):
def __init__(self, root_dir, url, rev):
self.root_dir = os.path.realpath(root_dir)
self.url = url
self.tree = self.scm.get_tree(rev)
self._setup_cache(repo)
self._fix_upstream(repo)
@cached_property
def scm(self):
return Git(self.root_dir)
return repo
def close(self):
if "scm" in self.__dict__:
self.scm.close()
def _setup_cache(self, repo):
repo.config["cache"]["dir"] = self.cache_dir
repo.cache = Cache(repo)
if self.cache_types:
repo.cache.local.cache_types = self.cache_types
def find_out_by_relpath(self, path):
raise OutputNotFoundError(path, self)
def _fix_upstream(self, repo):
if not os.path.isdir(self.url):
return
@contextmanager
def open_by_relpath(self, path, mode="r", encoding=None, **kwargs):
"""Opens a specified resource as a file object."""
tree = RepoTree(self)
try:
with tree.open(
path, mode=mode, encoding=encoding, **kwargs
) as fobj:
yield fobj
except FileNotFoundError:
raise PathMissingError(path, self.url)
rel_path = os.path.relpath(repo.root_dir, self.root_dir)
src_repo = Repo(PathInfo(self.url) / rel_path)
except NotDvcRepoError:
return
try:
remote_name = repo.config["core"].get("remote")
if remote_name:
self._fix_local_remote(repo, src_repo, remote_name)
else:
self._add_upstream(repo, src_repo)
finally:
src_repo.close()
@wrap_with(threading.Lock())
def _get_cache_dir(self):
try:
cache_dir = CACHE_DIRS[self.url]
except KeyError:
cache_dir = CACHE_DIRS[self.url] = tempfile.mkdtemp("dvc-cache")
return cache_dir
def _cached_clone(url, rev, for_write=False):
@@ -351,6 +394,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)
+52
View File
@@ -0,0 +1,52 @@
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Dict, Optional
HASH_DIR_SUFFIX = ".dir"
DirInfo = Dict[str, str]
@dataclass
class HashInfo:
PARAM_SIZE = "size"
PARAM_NFILES = "nfiles"
name: Optional[str]
value: Optional[str]
dir_info: Optional[DirInfo] = field(default=None, compare=False)
size: Optional[int] = field(default=None, compare=False)
nfiles: Optional[int] = field(default=None, compare=False)
def __bool__(self):
return bool(self.value)
@classmethod
def from_dict(cls, d):
_d = d.copy() if d else {}
size = _d.pop(cls.PARAM_SIZE, None)
nfiles = _d.pop(cls.PARAM_NFILES, None)
if not _d:
return cls(None, None)
((name, value),) = _d.items()
return cls(name, value, size=size, nfiles=nfiles)
def to_dict(self):
ret = OrderedDict()
if not self:
return ret
ret[self.name] = self.value
if self.size is not None:
ret[self.PARAM_SIZE] = self.size
if self.nfiles is not None:
ret[self.PARAM_NFILES] = self.nfiles
return ret
@property
def isdir(self):
if not self:
return False
return self.value.endswith(HASH_DIR_SUFFIX)
+134 -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,12 @@ class DvcIgnoreFilterNoop:
def is_ignored_file(self, _):
return False
def check_ignore(self, path):
return _no_match(path)
def is_ignored(self, _):
return False
class DvcIgnoreFilter:
@staticmethod
@@ -127,10 +190,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 +230,33 @@ 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}
pattern_info = PatternInfo(
f"/{d}/", "in sub_repo:{}".format(d)
)
new_pattern = DvcIgnorePatterns([pattern_info], root)
old_pattern = self.ignores_trie_tree.longest_prefix(root).value
if old_pattern:
self.ignores_trie_tree[root] = DvcIgnorePatterns(
*merge_patterns(
old_pattern.pattern_list,
old_pattern.dirname,
["/{}/".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 +284,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 +292,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 +322,37 @@ 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 is_ignored(self, path):
# NOTE: can't use self.check_ignore(path).match for now, see
# https://github.com/iterative/dvc/issues/4555
return self.is_ignored_dir(path) or self.is_ignored_file(path)
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
+160
View File
@@ -0,0 +1,160 @@
import itertools
import os
import pathlib
import platform
import uuid
from dvc.exceptions import DvcException, NotDvcRepoError
from dvc.repo import Repo
from dvc.scm.base import SCMError
from dvc.system import System
from dvc.tree import TREES, get_tree_cls, get_tree_config
from dvc.utils import error_link
from dvc.utils.pkg import PKG
from dvc.version import __version__
try:
import psutil
except ImportError:
psutil = None
if PKG is None:
package = ""
else:
package = f"({PKG})"
def get_dvc_info():
info = [
f"DVC version: {__version__} {package}",
"---------------------------------",
f"Platform: Python {platform.python_version()} on "
f"{platform.platform()}",
f"Supports: {_get_supported_remotes()}",
]
try:
repo = Repo()
# cache_dir might not exist yet (e.g. after `dvc init`), and we
# can't auto-create it, as it might cause issues if the user
# later decides to enable shared cache mode with
# `dvc config cache.shared group`.
if os.path.exists(repo.cache.local.cache_dir):
info.append(
"Cache types: {}".format(_get_linktype_support_info(repo))
)
if psutil:
fs_type = get_fs_type(repo.cache.local.cache_dir)
info.append(f"Cache directory: {fs_type}")
else:
info.append("Cache types: " + error_link("no-dvc-cache"))
info.append(f"Caches: {_get_caches(repo.cache)}")
info.append(f"Remotes: {_get_remotes(repo.config)}")
except NotDvcRepoError:
pass
except SCMError:
info.append("Repo: dvc, git (broken)")
else:
root_directory = repo.root_dir
if psutil:
fs_root = get_fs_type(os.path.abspath(root_directory))
info.append(f"Workspace directory: {fs_root}")
info.append("Repo: {}".format(_get_dvc_repo_info(repo)))
return "\n".join(info)
def _get_caches(cache):
caches = (
cache_type
for cache_type, cache_instance in cache.by_scheme()
if cache_instance
)
# Caches will be always non-empty including the local cache
return ", ".join(caches)
def _get_remotes(config):
schemes = (
get_tree_cls(get_tree_config(config, name=remote)).scheme
for remote in config["remote"]
)
return ", ".join(schemes) or "None"
def _get_linktype_support_info(repo):
links = {
"reflink": (System.reflink, None),
"hardlink": (System.hardlink, System.is_hardlink),
"symlink": (System.symlink, System.is_symlink),
}
fname = "." + str(uuid.uuid4())
src = os.path.join(repo.cache.local.cache_dir, fname)
open(src, "w").close()
dst = os.path.join(repo.root_dir, fname)
cache = []
for name, (link, is_link) in links.items():
try:
link(src, dst)
status = "supported"
if is_link and not is_link(dst):
status = "broken"
os.unlink(dst)
except DvcException:
status = "not supported"
if status == "supported":
cache.append(name)
os.remove(src)
return ", ".join(cache)
def _get_supported_remotes():
supported_remotes = []
for tree_cls in TREES:
if not tree_cls.get_missing_deps():
supported_remotes.append(tree_cls.scheme)
if len(supported_remotes) == len(TREES):
return "All remotes"
if len(supported_remotes) == 1:
return supported_remotes
return ", ".join(supported_remotes)
def get_fs_type(path):
partition = {
pathlib.Path(part.mountpoint): (part.fstype + " on " + part.device)
for part in psutil.disk_partitions(all=True)
}
path = pathlib.Path(path)
for parent in itertools.chain([path], path.parents):
if parent in partition:
return partition[parent]
return ("unknown", "none")
def _get_dvc_repo_info(self):
if self.config.get("core", {}).get("no_scm", False):
return "dvc (no_scm)"
if self.root_dir != self.scm.root_dir:
return "dvc (subdir), git"
return "dvc, git"
+74 -22
View File
@@ -2,22 +2,26 @@
import hashlib
import os
import time
from abc import ABC, abstractmethod
from datetime import timedelta
import flufl.lock
import zc.lockfile
from funcy import retry
from dvc.exceptions import DvcException
from dvc.progress import Tqdm
from dvc.utils import format_link
DEFAULT_TIMEOUT = 5
DEFAULT_TIMEOUT = 3
FAILED_TO_LOCK_MESSAGE = (
"cannot perform the command because another DVC process seems to be "
"running on this project. If that is not the case, manually remove "
"`.dvc/tmp/lock` and try again."
"Unable to acquire lock. Most likely another DVC process is running or "
"was terminated abruptly. Check the page {} for other possible reasons "
"and to learn how to resolve this."
).format(
format_link("https://dvc.org/doc/user-guide/troubleshooting#lock-issue")
)
@@ -25,20 +29,74 @@ class LockError(DvcException):
"""Thrown when unable to acquire the lock for DVC repo."""
class Lock:
class LockBase(ABC):
@abstractmethod
def __init__(self, lockfile):
self._lockfile = lockfile
@property
def lockfile(self):
return self._lockfile
@abstractmethod
def lock(self):
pass
@abstractmethod
def unlock(self):
pass
@property
@abstractmethod
def is_locked(self):
pass
@abstractmethod
def __enter__(self):
pass
@abstractmethod
def __exit__(self, typ, value, tbck):
pass
class LockNoop(LockBase):
def __init__(
self, *args, **kwargs
): # pylint: disable=super-init-not-called
self._lock = False
def lock(self):
self._lock = True
def unlock(self):
self._lock = False
@property
def is_locked(self):
return self._lock
def __enter__(self):
self.lock()
def __exit__(self, typ, value, tbck):
self.unlock()
class Lock(LockBase):
"""Class for DVC repo lock.
Uses zc.lockfile as backend.
"""
def __init__(self, lockfile, friendly=False, **kwargs):
super().__init__(lockfile)
self._friendly = friendly
self.lockfile = lockfile
self._lock = None
@property
def files(self):
return [self.lockfile]
return [self._lockfile]
def _do_lock(self):
try:
@@ -51,18 +109,15 @@ class Lock:
)
),
):
self._lock = zc.lockfile.LockFile(self.lockfile)
self._lock = zc.lockfile.LockFile(self._lockfile)
except zc.lockfile.LockError:
raise LockError(FAILED_TO_LOCK_MESSAGE)
def lock(self):
try:
self._do_lock()
return
except LockError:
time.sleep(DEFAULT_TIMEOUT)
self._do_lock()
retries = 6
delay = DEFAULT_TIMEOUT / retries
lock_retry = retry(retries, LockError, timeout=delay)(self._do_lock)
lock_retry()
def unlock(self):
self._lock.close()
@@ -79,7 +134,7 @@ class Lock:
self.unlock()
class HardlinkLock(flufl.lock.Lock):
class HardlinkLock(flufl.lock.Lock, LockBase):
"""Class for DVC repo lock.
Args:
@@ -93,6 +148,8 @@ class HardlinkLock(flufl.lock.Lock):
): # pylint: disable=super-init-not-called
import socket
super().__init__(lockfile)
self._tmp_dir = tmp_dir
# NOTE: this is basically Lock.__init__ copy-paste, except that
@@ -108,17 +165,12 @@ class HardlinkLock(flufl.lock.Lock):
# [1] https://github.com/iterative/dvc/issues/2582
self._hostname = socket.gethostname()
self._lockfile = lockfile
self._lifetime = timedelta(days=365) # Lock for good by default
self._separator = flufl.lock.SEP
self._set_claimfile()
self._owned = True
self._retry_errnos = []
@property
def lockfile(self):
return self._lockfile
def lock(self): # pylint: disable=arguments-differ
try:
super().lock(timedelta(seconds=DEFAULT_TIMEOUT))
+68 -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,40 @@ 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
prof.disable()
if not dump:
prof.print_stats(sort="cumtime")
return
prof.dump_stats(dump)
@contextmanager
def debug(enable):
try:
yield
return
except Exception:
if enable:
import pdb # noqa: T100
pdb.post_mortem()
raise
def main(argv=None): # noqa: C901
"""Run dvc CLI command.
Args:
@@ -37,20 +71,23 @@ 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):
with debug(args.pdb):
cmd = args.func(args)
ret = cmd.run()
except ConfigError:
logger.exception("configuration error")
ret = 251
@@ -65,22 +102,31 @@ 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:
from dvc.info import get_dvc_info
logger.exception("unexpected error")
dvc_info = get_dvc_info()
logger.debug("Version info for developers:\n%s", dvc_info)
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 +138,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
+45 -3
View File
@@ -4,24 +4,28 @@ from urllib.parse import urlparse
from funcy import collecting, project
from voluptuous import And, Any, Coerce, Length, Lower, Required, SetTo
from dvc.hash_info import HashInfo
from dvc.output.base import BaseOutput
from dvc.output.gs import GSOutput
from dvc.output.hdfs import HDFSOutput
from dvc.output.local import LocalOutput
from dvc.output.s3 import S3Output
from dvc.output.ssh import SSHOutput
from dvc.output.webhdfs import WebHDFSOutput
from dvc.scheme import Schemes
from ..tree import get_cloud_tree
from ..tree.hdfs import HDFSTree
from ..tree.local import LocalTree
from ..tree.s3 import S3Tree
from ..tree.webhdfs import WebHDFSTree
OUTS = [
HDFSOutput,
S3Output,
GSOutput,
SSHOutput,
WebHDFSOutput,
# NOTE: LocalOutput is the default choice
]
@@ -31,6 +35,7 @@ OUTS_MAP = {
Schemes.GS: GSOutput,
Schemes.SSH: SSHOutput,
Schemes.LOCAL: LocalOutput,
Schemes.WEBHDFS: WebHDFSOutput,
}
CHECKSUM_SCHEMA = Any(
@@ -51,6 +56,7 @@ CHECKSUMS_SCHEMA = {
LocalTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
S3Tree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
HDFSTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
WebHDFSTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
}
SCHEMA = CHECKSUMS_SCHEMA.copy()
@@ -59,10 +65,22 @@ SCHEMA[BaseOutput.PARAM_CACHE] = bool
SCHEMA[BaseOutput.PARAM_METRIC] = BaseOutput.METRIC_SCHEMA
SCHEMA[BaseOutput.PARAM_PLOT] = bool
SCHEMA[BaseOutput.PARAM_PERSIST] = bool
SCHEMA[BaseOutput.PARAM_CHECKPOINT] = bool
SCHEMA[HashInfo.PARAM_SIZE] = int
SCHEMA[HashInfo.PARAM_NFILES] = int
SCHEMA[BaseOutput.PARAM_DESC] = str
def _get(
stage, p, info=None, cache=True, metric=False, plot=False, persist=False
stage,
p,
info=None,
cache=True,
metric=False,
plot=False,
persist=False,
checkpoint=False,
desc=None,
):
parsed = urlparse(p)
@@ -77,6 +95,8 @@ def _get(
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
desc=desc,
)
for o in OUTS:
@@ -90,6 +110,8 @@ def _get(
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
desc=desc,
)
return LocalOutput(
stage,
@@ -100,6 +122,8 @@ def _get(
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
desc=desc,
)
@@ -111,6 +135,8 @@ def loadd_from(stage, d_list):
metric = d.pop(BaseOutput.PARAM_METRIC, False)
plot = d.pop(BaseOutput.PARAM_PLOT, False)
persist = d.pop(BaseOutput.PARAM_PERSIST, False)
checkpoint = d.pop(BaseOutput.PARAM_CHECKPOINT, False)
desc = d.pop(BaseOutput.PARAM_DESC, False)
ret.append(
_get(
stage,
@@ -120,13 +146,21 @@ def loadd_from(stage, d_list):
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
desc=desc,
)
)
return ret
def loads_from(
stage, s_list, use_cache=True, metric=False, plot=False, persist=False
stage,
s_list,
use_cache=True,
metric=False,
plot=False,
persist=False,
checkpoint=False,
):
return [
_get(
@@ -137,6 +171,7 @@ def loads_from(
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
)
for s in s_list
]
@@ -180,5 +215,12 @@ def load_from_pipeline(stage, s_list, typ="outs"):
from dvc.schema import PLOT_PROPS
plt_d, flags = _split_dict(flags, keys=PLOT_PROPS.keys())
extra = project(flags, ["cache", "persist"])
extra = project(
flags,
[
BaseOutput.PARAM_CACHE,
BaseOutput.PARAM_PERSIST,
BaseOutput.PARAM_CHECKPOINT,
],
)
yield _get(stage, path, {}, plot=plt_d or plot, metric=metric, **extra)
+133 -57
View File
@@ -1,6 +1,7 @@
import logging
import os
from copy import copy
from typing import Type
from urllib.parse import urlparse
from voluptuous import Any
@@ -8,10 +9,13 @@ from voluptuous import Any
import dvc.prompt as prompt
from dvc.cache import NamedCache
from dvc.exceptions import (
CheckoutError,
CollectCacheError,
DvcException,
MergeError,
RemoteCacheRequiredError,
)
from dvc.hash_info import HashInfo
from ..tree.base import BaseTree
@@ -45,6 +49,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
@@ -52,6 +62,7 @@ class BaseOutput:
PARAM_PATH = "path"
PARAM_CACHE = "cache"
PARAM_CHECKPOINT = "checkpoint"
PARAM_METRIC = "metric"
PARAM_METRIC_TYPE = "type"
PARAM_METRIC_XPATH = "xpath"
@@ -64,6 +75,7 @@ class BaseOutput:
PARAM_PLOT_TITLE = "title"
PARAM_PLOT_HEADER = "header"
PARAM_PERSIST = "persist"
PARAM_DESC = "desc"
METRIC_SCHEMA = Any(
None,
@@ -74,9 +86,10 @@ class BaseOutput:
},
)
DoesNotExistError = OutputDoesNotExistError
IsNotFileOrDirError = OutputIsNotFileOrDirError
IsStageFileError = OutputIsStageFileError
DoesNotExistError = OutputDoesNotExistError # type: Type[DvcException]
IsNotFileOrDirError = OutputIsNotFileOrDirError # type: Type[DvcException]
IsStageFileError = OutputIsStageFileError # type: Type[DvcException]
IsIgnoredError = OutputIsIgnoredError # type: Type[DvcException]
sep = "/"
@@ -90,8 +103,10 @@ class BaseOutput:
metric=False,
plot=False,
persist=False,
checkpoint=False,
desc=None,
):
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:
#
@@ -105,7 +120,7 @@ class BaseOutput:
self.stage = stage
self.repo = stage.repo if stage else None
self.def_path = path
self.info = info
self.hash_info = HashInfo.from_dict(info)
if tree:
self.tree = tree
else:
@@ -114,6 +129,8 @@ class BaseOutput:
self.metric = False if self.IS_DEPENDENCY else metric
self.plot = False if self.IS_DEPENDENCY else plot
self.persist = persist
self.checkpoint = checkpoint
self.desc = desc
self.path_info = self._parse_path(tree, path)
if self.use_cache and self.cache is None:
@@ -154,7 +171,7 @@ class BaseOutput:
@property
def dir_cache(self):
return self.cache.get_dir_cache(self.checksum)
return self.cache.get_dir_cache(self.hash_info)
@classmethod
def supported(cls, url):
@@ -162,58 +179,50 @@ class BaseOutput:
@property
def cache_path(self):
return self.cache.tree.hash_to_path_info(self.checksum).url
return self.cache.tree.hash_to_path_info(self.hash_info.value).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)
def get_hash(self):
if not self.use_cache:
return self.tree.get_hash(self.path_info)
return self.cache.get_hash(self.tree, self.path_info)
@property
def is_dir_checksum(self):
return self.tree.is_dir_hash(self.checksum)
return self.hash_info.isdir
@property
def exists(self):
return self.tree.exists(self.path_info)
def save_info(self):
return self.tree.save_info(self.path_info)
def changed_checksum(self):
return self.checksum != self.get_checksum()
return self.hash_info != self.get_hash()
def changed_cache(self, filter_info=None):
if not self.use_cache or not self.checksum:
if not self.use_cache or not self.hash_info:
return True
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"}
return self.cache.changed_cache(
self.hash_info, filter_info=filter_info
)
def workspace_status(self):
if not self.exists:
return {str(self): "deleted"}
if self.changed_checksum():
return {str(self): "modified"}
if not self.checksum:
if not self.hash_info:
return {str(self): "new"}
return {}
def status(self):
if self.hash_info 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))
@@ -264,7 +273,7 @@ class BaseOutput:
self.verify_metric()
if not self.use_cache:
self.info = self.save_info()
self.hash_info = self.get_hash()
if not self.IS_DEPENDENCY:
logger.debug(
"Output '%s' doesn't use cache. Skipping saving.", self
@@ -277,19 +286,26 @@ class BaseOutput:
logger.debug("Output '%s' didn't change. Skipping saving.", self)
return
self.info = self.save_info()
self.hash_info = self.get_hash()
def commit(self):
if not self.exists:
raise self.DoesNotExistError(self)
assert self.hash_info
if self.use_cache:
self.cache.save(self.path_info, self.cache.tree, self.info)
self.cache.save(self.path_info, self.cache.tree, self.hash_info)
def dumpd(self):
ret = copy(self.info)
ret = copy(self.hash_info.to_dict())
ret[self.PARAM_PATH] = self.def_path
if self.IS_DEPENDENCY:
return ret
if self.desc:
ret[self.PARAM_DESC] = self.desc
if not self.use_cache:
ret[self.PARAM_CACHE] = self.use_cache
@@ -309,6 +325,9 @@ class BaseOutput:
if self.persist:
ret[self.PARAM_PERSIST] = self.persist
if self.checkpoint:
ret[self.PARAM_CHECKPOINT] = self.checkpoint
return ret
def verify_metric(self):
@@ -323,6 +342,8 @@ class BaseOutput:
progress_callback=None,
relink=False,
filter_info=None,
allow_missing=False,
**kwargs,
):
if not self.use_cache:
if progress_callback:
@@ -331,14 +352,20 @@ class BaseOutput:
)
return None
return self.cache.checkout(
self.path_info,
self.info,
force=force,
progress_callback=progress_callback,
relink=relink,
filter_info=filter_info,
)
try:
return self.cache.checkout(
self.path_info,
self.hash_info,
force=force,
progress_callback=progress_callback,
relink=relink,
filter_info=filter_info,
**kwargs,
)
except CheckoutError:
if allow_missing or self.checkpoint:
return None
raise
def remove(self, ignore_remove=False):
self.tree.remove(self.path_info)
@@ -367,7 +394,7 @@ class BaseOutput:
return 0
return self.cache.get_files_number(
self.path_info, self.checksum, filter_info
self.path_info, self.hash_info, filter_info
)
def unprotect(self):
@@ -377,12 +404,15 @@ class BaseOutput:
def get_dir_cache(self, **kwargs):
if not self.is_dir_checksum:
raise DvcException("cannot get dir cache for file checksum")
if self.cache.changed_cache_file(self.checksum):
if self.cache.changed_cache_file(self.hash_info):
self.repo.cloud.pull(
NamedCache.make("local", self.checksum, str(self)),
NamedCache.make("local", self.hash_info.value, str(self)),
show_checksums=False,
**kwargs,
)
self.hash_info.dir_info = None
if not self.hash_info.dir_info:
self.cache.set_dir_info(self.hash_info)
return self.dir_cache
def collect_used_dir_cache(
@@ -414,7 +444,7 @@ class BaseOutput:
except DvcException:
logger.debug(f"failed to pull cache for '{self}'")
if self.cache.changed_cache_file(self.checksum):
if self.cache.changed_cache_file(self.hash_info):
msg = (
"Missing cache for directory '{}'. "
"Cache for files inside will be lost. "
@@ -431,9 +461,7 @@ class BaseOutput:
path = str(self.path_info)
filter_path = str(filter_info) if filter_info else None
is_win = os.name == "nt"
for entry in self.dir_cache:
checksum = entry[self.tree.PARAM_CHECKSUM]
entry_relpath = entry[self.tree.PARAM_RELPATH]
for entry_relpath, entry_hash_info in self.dir_cache.items():
if is_win:
entry_relpath = entry_relpath.replace("/", os.sep)
entry_path = os.path.join(path, entry_relpath)
@@ -442,7 +470,7 @@ class BaseOutput:
or entry_path == filter_path
or entry_path.startswith(filter_path + os.sep)
):
cache.add(self.scheme, checksum, entry_path)
cache.add(self.scheme, entry_hash_info.value, entry_path)
return cache
@@ -464,7 +492,7 @@ class BaseOutput:
cache.external[dep.repo_pair].add(dep.def_path)
return cache
if not self.checksum:
if not self.hash_info:
msg = (
"Output '{}'({}) is missing version info. "
"Cache for it will not be collected. "
@@ -483,20 +511,68 @@ class BaseOutput:
logger.warning(msg)
return NamedCache()
ret = NamedCache.make(self.scheme, self.checksum, str(self))
ret = NamedCache.make(self.scheme, self.hash_info.value, str(self))
if not self.is_dir_checksum:
return ret
ret.add_child_cache(
self.checksum, self.collect_used_dir_cache(**kwargs),
self.hash_info.value, self.collect_used_dir_cache(**kwargs),
)
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()
ignored = [
self.tree.PARAM_CHECKSUM,
HashInfo.PARAM_SIZE,
HashInfo.PARAM_NFILES,
]
for opt in ignored:
my.pop(opt, None)
other.pop(opt, None)
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.hash_info
else:
ancestor_info = None
self._check_can_merge(self)
self._check_can_merge(other)
self.hash_info = self.cache.merge(
ancestor_info, self.hash_info, other.hash_info
)
+2 -2
View File
@@ -1,7 +1,7 @@
from dvc.output.s3 import S3Output
from dvc.output.base import BaseOutput
from ..tree.gs import GSTree
class GSOutput(S3Output):
class GSOutput(BaseOutput):
TREE_CLS = GSTree
+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 {}."
+7
View File
@@ -0,0 +1,7 @@
from dvc.output.base import BaseOutput
from ..tree.webhdfs import WebHDFSTree
class WebHDFSOutput(BaseOutput):
TREE_CLS = WebHDFSTree
+287
View File
@@ -0,0 +1,287 @@
import logging
import os
from collections import defaultdict
from collections.abc import Mapping, Sequence
from copy import deepcopy
from itertools import starmap
from typing import TYPE_CHECKING, List, Set
from funcy import join
from dvc.dependency.param import ParamsDependency
from dvc.exceptions import DvcException
from dvc.parsing.interpolate import ParseError
from dvc.path_info import PathInfo
from dvc.utils import relpath
from .context import (
Context,
ContextError,
KeyNotInContext,
MergeError,
Meta,
Node,
ParamsFileNotFound,
SetError,
)
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
STAGES_KWD = "stages"
VARS_KWD = "vars"
WDIR_KWD = "wdir"
DEFAULT_PARAMS_FILE = ParamsDependency.DEFAULT_PARAMS_FILE
PARAMS_KWD = "params"
FOREACH_KWD = "foreach"
DO_KWD = "do"
SET_KWD = "set"
DEFAULT_SENTINEL = object()
JOIN = "@"
class ResolveError(DvcException):
pass
def format_and_raise(exc, msg, path):
spacing = "\n" if isinstance(exc, (ParseError, MergeError)) else " "
message = f"failed to parse {msg} in '{path}':{spacing}{str(exc)}"
# FIXME: cannot reraise because of how we log "cause" of the exception
# the error message is verbose, hence need control over the spacing
_reraise_err(ResolveError, message, from_exc=exc)
def _reraise_err(exc_cls, *args, from_exc=None):
err = exc_cls(*args)
if from_exc and logger.isEnabledFor(logging.DEBUG):
raise err from from_exc
raise err
class DataResolver:
def __init__(self, repo: "Repo", wdir: PathInfo, d: dict):
self.data: dict = d
self.wdir = wdir
self.repo = repo
self.tree = self.repo.tree
self.imported_files: Set[str] = set()
self.relpath = relpath(self.wdir / "dvc.yaml")
to_import: PathInfo = wdir / DEFAULT_PARAMS_FILE
if self.tree.exists(to_import):
self.imported_files = {os.path.abspath(to_import)}
self.global_ctx = Context.load_from(self.tree, to_import)
else:
self.global_ctx = Context()
logger.debug(
"%s does not exist, it won't be used in parametrization",
to_import,
)
vars_ = d.get(VARS_KWD, [])
try:
self.load_from_vars(
self.global_ctx, vars_, wdir, skip_imports=self.imported_files
)
except (ParamsFileNotFound, MergeError) as exc:
format_and_raise(exc, "'vars'", self.relpath)
def load_from_vars(
self,
context: "Context",
vars_: List,
wdir: PathInfo,
skip_imports: Set[str],
stage_name: str = None,
):
stage_name = stage_name or ""
for index, item in enumerate(vars_):
assert isinstance(item, (str, dict))
if isinstance(item, str):
path_info = wdir / item
path = os.path.abspath(path_info)
if path in skip_imports:
continue
context.merge_from(self.tree, path_info)
skip_imports.add(path)
else:
joiner = "." if stage_name else ""
meta = Meta(source=f"{stage_name}{joiner}vars[{index}]")
context.merge_update(Context(item, meta=meta))
def _resolve_entry(self, name: str, definition):
context = Context.clone(self.global_ctx)
if FOREACH_KWD in definition:
assert DO_KWD in definition
self.set_context_from(
context, definition.get(SET_KWD, {}), source=[name, "set"]
)
return self._foreach(
context, name, definition[FOREACH_KWD], definition[DO_KWD]
)
try:
return self._resolve_stage(context, name, definition)
except ContextError as exc:
format_and_raise(exc, f"stage '{name}'", self.relpath)
def resolve(self):
stages = self.data.get(STAGES_KWD, {})
data = join(starmap(self._resolve_entry, stages.items())) or {}
logger.trace("Resolved dvc.yaml:\n%s", data)
return {STAGES_KWD: data}
def _resolve_stage(self, context: Context, name: str, definition) -> dict:
definition = deepcopy(definition)
self.set_context_from(
context, definition.pop(SET_KWD, {}), source=[name, "set"]
)
wdir = self._resolve_wdir(context, name, definition.get(WDIR_KWD))
if self.wdir != wdir:
logger.debug(
"Stage %s has different wdir than dvc.yaml file", name
)
vars_ = definition.pop(VARS_KWD, [])
# FIXME: Should `vars` be templatized?
self.load_from_vars(
context,
vars_,
wdir,
skip_imports=deepcopy(self.imported_files),
stage_name=name,
)
logger.trace( # type: ignore[attr-defined]
"Context during resolution of stage %s:\n%s", name, context
)
with context.track():
resolved = {}
for key, value in definition.items():
# NOTE: we do not pop "wdir", and resolve it again
# this does not affect anything and is done to try to
# track the source of `wdir` interpolation.
# This works because of the side-effect that we do not
# allow overwriting and/or str interpolating complex objects.
# Fix if/when those assumptions are no longer valid.
try:
resolved[key] = context.resolve(value)
except (ParseError, KeyNotInContext) as exc:
format_and_raise(
exc, f"'stages.{name}.{key}'", self.relpath
)
# FIXME: Decide if we should track them or not (it does right now)
params = resolved.get(PARAMS_KWD, []) + self._resolve_params(
context, wdir
)
if params:
resolved[PARAMS_KWD] = params
return {name: resolved}
def _resolve_params(self, context: Context, wdir):
tracked = defaultdict(set)
for src, keys in context.tracked.items():
tracked[str(PathInfo(src).relative_to(wdir))].update(keys)
return [{file: list(keys)} for file, keys in tracked.items()]
def _resolve_wdir(
self, context: Context, name: str, wdir: str = None
) -> PathInfo:
if not wdir:
return self.wdir
try:
wdir = str(context.resolve_str(wdir, unwrap=True))
except (ContextError, ParseError) as exc:
format_and_raise(exc, f"'stages.{name}.wdir'", self.relpath)
return self.wdir / str(wdir)
def _foreach(self, context: Context, name: str, foreach_data, do_data):
iterable = self._resolve_foreach_data(context, name, foreach_data)
args = (context, name, do_data, iterable)
it = (
range(len(iterable))
if not isinstance(iterable, Mapping)
else iterable
)
gen = (self._each_iter(*args, i) for i in it)
return join(gen)
def _each_iter(self, context: Context, name: str, do_data, iterable, key):
value = iterable[key]
c = Context.clone(context)
suffix = c["item"] = value
if isinstance(iterable, Mapping):
suffix = c["key"] = key
generated = f"{name}{JOIN}{suffix}"
try:
return self._resolve_stage(c, generated, do_data)
except ContextError as exc:
# pylint: disable=no-member
if isinstance(exc, MergeError) and exc.key in self._inserted_keys(
iterable
):
raise ResolveError(
f"attempted to redefine '{exc.key}' in stage '{generated}'"
" generated through 'foreach'"
)
format_and_raise(
exc, f"stage '{generated}' (gen. from '{name}')", self.relpath
)
def _resolve_foreach_data(
self, context: "Context", name: str, foreach_data
):
try:
iterable = context.resolve(foreach_data, unwrap=False)
except (ContextError, ParseError) as exc:
format_and_raise(exc, f"'stages.{name}.foreach'", self.relpath)
if isinstance(iterable, str) or not isinstance(
iterable, (Sequence, Mapping)
):
raise ResolveError(
f"failed to resolve 'stages.{name}.foreach'"
f" in '{self.relpath}': expected list/dictionary, got "
+ type(
iterable.value if isinstance(iterable, Node) else iterable
).__name__
)
warn_for = [k for k in self._inserted_keys(iterable) if k in context]
if warn_for:
logger.warning(
"%s %s already specified, "
"will be overwritten for stages generated from '%s'",
" and ".join(warn_for),
"is" if len(warn_for) == 1 else "are",
name,
)
return iterable
@staticmethod
def _inserted_keys(iterable):
keys = ["item"]
if isinstance(iterable, Mapping):
keys.append("key")
return keys
@classmethod
def set_context_from(cls, context: Context, to_set, source=None):
try:
for key, value in to_set.items():
src_set = [*(source or []), key]
context.set(key, value, source=".".join(src_set))
except SetError as exc:
_reraise_err(ResolveError, str(exc), from_exc=exc)
+436
View File
@@ -0,0 +1,436 @@
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Mapping, MutableMapping, MutableSequence, Sequence
from contextlib import contextmanager
from copy import deepcopy
from dataclasses import dataclass, field, replace
from typing import Any, List, Optional, Union
from funcy import identity, rpartial
from dvc.exceptions import DvcException
from dvc.parsing.interpolate import (
ParseError,
get_expression,
get_matches,
is_exact_string,
is_interpolated_string,
str_interpolate,
)
from dvc.path_info import PathInfo
from dvc.utils import relpath
from dvc.utils.serialize import LOADERS
SeqOrMap = Union[Sequence, Mapping]
class ContextError(DvcException):
pass
class SetError(ContextError):
pass
class MergeError(ContextError):
def __init__(self, key, new, into):
self.key = key
if not isinstance(into[key], Node) or not isinstance(new, Node):
super().__init__(
f"cannot merge '{key}' as it already exists in {into}"
)
return
preexisting = into[key].meta.source
new_src = new.meta.source
path = new.meta.path()
super().__init__(
f"cannot redefine '{path}' from '{new_src}'"
f" as it already exists in '{preexisting}'"
)
class ParamsFileNotFound(ContextError):
pass
class KeyNotInContext(ContextError):
def __init__(self, key) -> None:
self.key = key
super().__init__(f"Could not find '{key}'")
def _merge(into, update, overwrite):
for key, val in update.items():
if isinstance(into.get(key), Mapping) and isinstance(val, Mapping):
_merge(into[key], val, overwrite)
else:
if key in into and not overwrite:
raise MergeError(key, val, into)
into[key] = val
assert isinstance(into[key], Node)
@dataclass
class Meta:
source: Optional[str] = None
dpaths: List[str] = field(default_factory=list)
local: bool = True
@staticmethod
def update_path(meta: "Meta", path: Union[str, int]):
dpaths = meta.dpaths[:] + [str(path)]
return replace(meta, dpaths=dpaths)
def __str__(self):
string = self.source or "<local>"
string += ":" + self.path()
return string
def path(self):
return ".".join(self.dpaths)
def _default_meta():
return Meta(source=None)
class Node:
def get_sources(self):
raise NotImplementedError
@property
@abstractmethod
def value(self):
pass
@dataclass
class Value(Node):
_value: Any
meta: Meta = field(
compare=False, default_factory=_default_meta, repr=False
)
def __repr__(self):
return repr(self._value)
def __str__(self) -> str:
return str(self._value)
def get_sources(self):
return {self.meta.source: self.meta.path()}
@property
def value(self):
return self._value
PRIMITIVES = (int, float, str, bytes, bool)
class Container(Node, ABC):
meta: Meta
data: Union[list, dict]
_key_transform = staticmethod(identity)
def __init__(self, meta=None) -> None:
self.meta = meta or _default_meta()
def _convert(self, key, value):
meta = Meta.update_path(self.meta, key)
return self._convert_with_meta(value, meta)
@staticmethod
def _convert_with_meta(value, meta: Meta = None):
if value is None or isinstance(value, PRIMITIVES):
assert meta
return Value(value, meta=meta)
elif isinstance(value, Node):
return value
elif isinstance(value, (list, dict)):
assert meta
container = CtxDict if isinstance(value, dict) else CtxList
return container(value, meta=meta)
else:
msg = (
"Unsupported value of type "
f"'{type(value).__name__}' in '{meta}'"
)
raise TypeError(msg)
def __repr__(self):
return repr(self.data)
def __getitem__(self, key):
return self.data[key]
def __setitem__(self, key, value):
self.data[key] = self._convert(key, value)
def __delitem__(self, key):
del self.data[key]
def __len__(self):
return len(self.data)
def __iter__(self):
return iter(self.data)
def __eq__(self, o):
container = type(self)
if isinstance(o, container):
return o.data == self.data
return container(o) == self
def select(self, key: str):
index, *rems = key.split(sep=".", maxsplit=1)
index = index.strip()
index = self._key_transform(index)
try:
d = self[index]
except LookupError as exc:
raise ValueError(
f"Could not find '{index}' in {self.data}"
) from exc
if not rems:
return d
rem = rems[0]
if not isinstance(d, Container):
raise ValueError(
f"{index} is a primitive value, cannot get '{rem}'"
)
return d.select(rem)
def get_sources(self):
return {}
class CtxList(Container, MutableSequence):
_key_transform = staticmethod(int)
def __init__(self, values: Sequence, meta: Meta = None):
super().__init__(meta=meta)
self.data: list = []
self.extend(values)
def insert(self, index: int, value):
self.data.insert(index, self._convert(index, value))
def get_sources(self):
return {self.meta.source: self.meta.path()}
@property
def value(self):
return [node.value for node in self]
class CtxDict(Container, MutableMapping):
def __init__(self, mapping: Mapping = None, meta: Meta = None, **kwargs):
super().__init__(meta=meta)
self.data: dict = {}
if mapping:
self.update(mapping)
self.update(kwargs)
def __setitem__(self, key, value):
if not isinstance(key, str):
# limitation for the interpolation
# ignore other kinds of keys
return
return super().__setitem__(key, value)
def merge_update(self, *args, overwrite=False):
for d in args:
_merge(self, d, overwrite=overwrite)
@property
def value(self):
return {key: node.value for key, node in self.items()}
class Context(CtxDict):
def __init__(self, *args, **kwargs):
"""
Top level mutable dict, with some helpers to create context and track
"""
super().__init__(*args, **kwargs)
self._track = False
self._tracked_data = defaultdict(set)
@contextmanager
def track(self):
self._track = True
yield
self._track = False
def _track_data(self, node):
if not self._track:
return
if node.meta and node.meta.local:
return
for source, keys in node.get_sources().items():
if not source:
continue
params_file = self._tracked_data[source]
keys = [keys] if isinstance(keys, str) else keys
params_file.update(keys)
@property
def tracked(self):
return self._tracked_data
def select(
self, key: str, unwrap=False
): # pylint: disable=arguments-differ
"""Select the item using key, similar to `__getitem__`
but can track the usage of the data on interpolation
as well and can get from nested data structure by using
"." separated key (eg: "key1.key2.key3")
Args:
key: key to select value from
unwrap: Convert CtxList/CtxDict/Value items to it's original data
Defaults to False. Note that the default is different from
`resolve`.
"""
try:
node = super().select(key)
except ValueError as exc:
raise KeyNotInContext(key) from exc
self._track_data(node)
assert isinstance(node, Node)
return node.value if unwrap else node
@classmethod
def load_from(cls, tree, path: PathInfo) -> "Context":
file = relpath(path)
if not tree.exists(path):
raise ParamsFileNotFound(f"'{file}' does not exist")
_, ext = os.path.splitext(file)
loader = LOADERS[ext]
meta = Meta(source=file, local=False)
return cls(loader(path, tree=tree), meta=meta)
def merge_from(self, tree, path: PathInfo, overwrite=False):
self.merge_update(Context.load_from(tree, path), overwrite=overwrite)
@classmethod
def clone(cls, ctx: "Context") -> "Context":
"""Clones given context."""
return cls(deepcopy(ctx.data))
def set(self, key, value, source=None):
"""
Sets a value, either non-interpolated values to a key,
or an interpolated string after resolving it.
>>> c = Context({"foo": "foo", "bar": [1, 2], "lorem": {"a": "z"}})
>>> c.set("foobar", "${bar}")
>>> c
{'foo': 'foo', 'bar': [1, 2], 'lorem': {'a': 'z'}, 'foobar': [1, 2]}
"""
try:
self._set(key, value, source)
except (ParseError, ValueError, ContextError) as exc:
sp = "\n" if isinstance(exc, ParseError) else " "
raise SetError(f"Failed to set '{source}':{sp}{str(exc)}") from exc
def _set(self, key, value, source):
if key in self:
raise ValueError(f"Cannot set '{key}', key already exists")
if isinstance(value, str):
self._check_joined_with_interpolation(key, value)
value = self.resolve_str(value, unwrap=False)
elif isinstance(value, (Sequence, Mapping)):
self._check_not_nested_collection(key, value)
self._check_interpolation_collection(key, value)
self[key] = self._convert_with_meta(value, Meta(source=source))
def resolve(self, src, unwrap=True):
"""Recursively resolves interpolation and returns resolved data.
Args:
src: Data (str/list/dict etc.) to resolve
unwrap: Unwrap CtxDict/CtxList/Value to it's original data if
inside `src`. Defaults to True.
>>> c = Context({"three": 3})
>>> c.resolve({"lst": [1, 2, "${three}"]})
{'lst': [1, 2, 3]}
"""
Seq = (list, tuple, set)
resolve = rpartial(self.resolve, unwrap)
if isinstance(src, Mapping):
return dict(map(resolve, kv) for kv in src.items())
elif isinstance(src, Seq):
return type(src)(map(resolve, src))
elif isinstance(src, str):
return self.resolve_str(src, unwrap=unwrap)
return src
def resolve_str(self, src: str, unwrap=True):
"""Resolves interpolated string to it's original value,
or in case of multiple interpolations, a combined string.
>>> c = Context({"enabled": True})
>>> c.resolve_str("${enabled}")
True
>>> c.resolve_str("enabled? ${enabled}")
'enabled? True'
"""
matches = get_matches(src)
if is_exact_string(src, matches):
# replace "${enabled}", if `enabled` is a boolean, with it's actual
# value rather than it's string counterparts.
expr = get_expression(matches[0])
return self.select(expr, unwrap=unwrap)
# but not "${num} days"
return str_interpolate(src, matches, self)
@staticmethod
def _check_not_nested_collection(key: str, value: SeqOrMap):
values = value.values() if isinstance(value, Mapping) else value
has_nested = any(
not isinstance(item, str) and isinstance(item, (Mapping, Sequence))
for item in values
)
if has_nested:
raise ValueError(f"Cannot set '{key}', has nested dict/list")
@staticmethod
def _check_interpolation_collection(key: str, value: SeqOrMap):
values = value.values() if isinstance(value, Mapping) else value
interpolated = any(is_interpolated_string(item) for item in values)
if interpolated:
raise ValueError(
f"Cannot set '{key}', "
"having interpolation inside "
f"'{type(value).__name__}' is not supported."
)
@staticmethod
def _check_joined_with_interpolation(key: str, value: str):
matches = get_matches(value)
if matches and not is_exact_string(value, matches):
raise ValueError(
f"Cannot set '{key}', "
"joining string with interpolated string "
"is not supported"
)
if __name__ == "__main__":
import doctest
doctest.testmod()
+115
View File
@@ -0,0 +1,115 @@
import re
import typing
from pyparsing import (
CharsNotIn,
ParseException,
ParserElement,
Suppress,
ZeroOrMore,
)
from dvc.exceptions import DvcException
from dvc.utils import colorize
if typing.TYPE_CHECKING:
from typing import List, Match
from .context import Context
LBRACK = "["
RBRACK = "]"
PERIOD = "."
KEYCRE = re.compile(
r"""
(?<!\\) # escape \${} or ${{}}
\$ # starts with $
(?:({{)|({)) # either starts with double braces or single
(.*?) # match every char inside
(?(1)}})(?(2)}) # end with same kinds of braces it opened with
""",
re.VERBOSE,
)
ParserElement.enablePackrat()
word = CharsNotIn(f"{PERIOD}{LBRACK}{RBRACK}")
idx = Suppress(LBRACK) + word + Suppress(RBRACK)
attr = Suppress(PERIOD) + word
parser = word + ZeroOrMore(attr ^ idx)
parser.setParseAction(PERIOD.join)
class ParseError(DvcException):
pass
def get_matches(template: str):
return list(KEYCRE.finditer(template))
def is_interpolated_string(val):
return bool(get_matches(val)) if isinstance(val, str) else False
def format_and_raise_parse_error(exc):
raise ParseError(_format_exc_msg(exc))
def _format_exc_msg(exc: ParseException):
exc.loc += 2 # 2 because we append `${` at the start of expr below
expr = exc.pstr
exc.pstr = "${" + exc.pstr + "}"
error = ParseException.explain(exc, depth=0)
_, pointer, *explains = error.splitlines()
pstr = "{brace_open}{expr}{brace_close}".format(
brace_open=colorize("${", color="blue"),
expr=colorize(expr, color="magenta"),
brace_close=colorize("}", color="blue"),
)
msg = "\n".join(explains)
pointer = colorize(pointer, color="red")
return "\n".join([pstr, pointer, colorize(msg, color="red", style="bold")])
def parse_expr(s: str):
try:
result = parser.parseString(s, parseAll=True)
except ParseException as exc:
format_and_raise_parse_error(exc)
joined = result.asList()
assert len(joined) == 1
return joined[0]
def get_expression(match: "Match"):
_, _, inner = match.groups()
return parse_expr(inner)
def str_interpolate(template: str, matches: "List[Match]", context: "Context"):
from .context import PRIMITIVES
index, buf = 0, ""
for match in matches:
start, end = match.span(0)
expr = get_expression(match)
value = context.select(expr, unwrap=True)
if value is not None and not isinstance(value, PRIMITIVES):
raise ParseError(
f"Cannot interpolate data of type '{type(value).__name__}'"
)
buf += template[index:start] + str(value)
index = end
buf += template[index:]
# regex already backtracks and avoids any `${` starting with
# backslashes(`\`). We just need to replace those by `${`.
return buf.replace(r"\${", "${")
def is_exact_string(src: str, matches: "List[Match]"):
return len(matches) == 1 and src == matches[0].group(0)
+30 -2
View File
@@ -2,6 +2,7 @@
import os
import pathlib
import posixpath
from typing import Callable
from urllib.parse import urlparse
from funcy import cached_property
@@ -75,6 +76,17 @@ class PathInfo(pathlib.PurePath, _BasePath):
n = len(other._cparts)
return len(self._cparts) > n and self._cparts[:n] == other._cparts
def relative_to(self, other): # pylint: disable=arguments-differ
# pathlib relative_to raises exception when one path is not a direct
# descendant of the other when os.path.relpath would return abspath.
# For DVC PathInfo we only need the relpath behavior.
# See: https://bugs.python.org/issue40358
try:
path = super().relative_to(other)
except ValueError:
path = relpath(self, other)
return self.__class__(path)
class WindowsPathInfo(PathInfo, pathlib.PureWindowsPath):
pass
@@ -113,7 +125,6 @@ class URLInfo(_BasePath):
p = urlparse(url)
assert not p.query and not p.params and not p.fragment
assert p.password is None
self._fill_parts(p.scheme, p.hostname, p.username, p.port, p.path)
@classmethod
@@ -243,7 +254,7 @@ class CloudURLInfo(URLInfo):
class HTTPURLInfo(URLInfo):
__hash__ = URLInfo.__hash__
__hash__: Callable[["HTTPURLInfo"], int] = URLInfo.__hash__
def __init__(self, url):
p = urlparse(url)
@@ -253,6 +264,15 @@ class HTTPURLInfo(URLInfo):
self.query = p.query
self.fragment = p.fragment
def replace(self, path=None):
return self.from_parts(
*self._base_parts,
params=self.params,
query=self.query,
fragment=self.fragment,
path=path,
)
@classmethod
def from_parts(
cls,
@@ -315,3 +335,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):
+6 -3
View File
@@ -31,9 +31,12 @@ class Tqdm(tqdm):
BAR_FMT_NOTOTAL = (
"{desc}{bar:b}|{postfix[info]}{n_fmt} [{elapsed}, {rate_fmt:>11}]"
)
BYTES_DEFAULTS = dict(
unit="B", unit_scale=True, unit_divisor=1024, miniters=1
)
BYTES_DEFAULTS = {
"unit": "B",
"unit_scale": True,
"unit_divisor": 1024,
"miniters": 1,
}
def __init__(
self,
+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")
-3
View File
@@ -1,13 +1,10 @@
from ..tree import get_cloud_tree
from .base import Remote
from .local import LocalRemote
from .ssh import SSHRemote
def get_remote(repo, **kwargs):
tree = get_cloud_tree(repo, **kwargs)
if tree.scheme == "local":
return LocalRemote(tree)
if tree.scheme == "ssh":
return SSHRemote(tree)
return Remote(tree)
+414 -101
View File
@@ -1,19 +1,59 @@
import errno
import hashlib
import logging
from functools import wraps
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial, wraps
from funcy import concat
from dvc.exceptions import DownloadError, UploadError
from dvc.hash_info import HashInfo
from ..progress import Tqdm
from .index import RemoteIndex, RemoteIndexNoop
logger = logging.getLogger(__name__)
STATUS_OK = 1
STATUS_MISSING = 2
STATUS_NEW = 3
STATUS_DELETED = 4
STATUS_MAP = {
# (local_exists, remote_exists)
(True, True): STATUS_OK,
(False, False): STATUS_MISSING,
(True, False): STATUS_NEW,
(False, True): STATUS_DELETED,
}
def _log_exceptions(func, operation):
@wraps(func)
def wrapper(from_info, to_info, *args, **kwargs):
try:
func(from_info, to_info, *args, **kwargs)
return 0
except Exception as exc: # pylint: disable=broad-except
# NOTE: this means we ran out of file descriptors and there is no
# reason to try to proceed, as we will hit this error anyways.
# pylint: disable=no-member
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
raise
logger.exception(
"failed to %s '%s' to '%s'", operation, from_info, to_info
)
return 1
return wrapper
def index_locked(f):
@wraps(f)
def wrapper(obj, named_cache, remote, *args, **kwargs):
if hasattr(remote, "index"):
with remote.index:
return f(obj, named_cache, remote, *args, **kwargs)
return f(obj, named_cache, remote, *args, **kwargs)
def wrapper(obj, *args, **kwargs):
with obj.index:
return f(obj, *args, **kwargs)
return wrapper
@@ -28,8 +68,11 @@ class Remote:
INDEX_CLS = RemoteIndex
def __init__(self, tree):
from dvc.cache import get_cloud_cache
self.tree = tree
self.repo = tree.repo
self.cache = get_cloud_cache(self.tree)
config = tree.config
url = config.get("url")
@@ -47,123 +90,393 @@ class Remote:
path_info=self.tree.path_info or "No path",
)
@property
def cache(self):
return getattr(self.repo.cache, self.tree.scheme)
@index_locked
def gc(self, *args, **kwargs):
removed = self.cache.gc(*args, **kwargs)
def hashes_exist(self, hashes, jobs=None, name=None):
"""Check if the given hashes are stored in the remote.
if removed:
self.index.clear()
There are two ways of performing this check:
return removed
- Traverse method: Get a list of all the files in the remote
(traversing the cache directory) and compare it with
the given hashes. Cache entries will be retrieved in parallel
threads according to prefix (i.e. entries starting with, "00...",
"01...", and so on) and a progress bar will be displayed.
- Exists method: For each given hash, run the `exists`
method and filter the hashes that aren't on the remote.
This is done in parallel threads.
It also shows a progress bar when performing the check.
The reason for such an odd logic is that most of the remotes
take much shorter time to just retrieve everything they have under
a certain prefix (e.g. s3, gs, ssh, hdfs). Other remotes that can
check if particular file exists much quicker, use their own
implementation of hashes_exist (see ssh, local).
Which method to use will be automatically determined after estimating
the size of the remote cache, and comparing the estimated size with
len(hashes). To estimate the size of the remote cache, we fetch
a small subset of cache entries (i.e. entries starting with "00...").
Based on the number of entries in that subset, the size of the full
cache can be estimated, since the cache is evenly distributed according
to hash.
Returns:
A list with hashes that were found in the remote
"""
# Remotes which do not use traverse prefix should override
# hashes_exist() (see ssh, local)
assert self.tree.TRAVERSE_PREFIX_LEN >= 2
@index_locked
def status(
self,
cache,
named_cache,
jobs=None,
show_checksums=False,
download=False,
log_missing=True,
):
# Return flattened dict containing all status info
dir_status, file_status, _ = self._status(
cache,
named_cache,
jobs=jobs,
show_checksums=show_checksums,
download=download,
log_missing=log_missing,
)
return dict(dir_status, **file_status)
def hashes_exist(self, hashes, **kwargs):
hashes = set(hashes)
indexed_hashes = set(self.index.intersection(hashes))
hashes -= indexed_hashes
indexed_hashes = list(indexed_hashes)
logger.debug("Matched '{}' indexed hashes".format(len(indexed_hashes)))
if not hashes:
return indexed_hashes
if len(hashes) == 1 or not self.tree.CAN_TRAVERSE:
remote_hashes = self.tree.list_hashes_exists(hashes, jobs, name)
return list(indexed_hashes) + remote_hashes
return indexed_hashes + self.cache.hashes_exist(list(hashes), **kwargs)
# Max remote size allowed for us to use traverse method
remote_size, remote_hashes = self.tree.estimate_remote_size(
hashes, name
def _status(
self,
cache,
named_cache,
jobs=None,
show_checksums=False,
download=False,
log_missing=True,
):
"""Return a tuple of (dir_status_info, file_status_info, dir_contents).
dir_status_info contains status for .dir files, file_status_info
contains status for all other files, and dir_contents is a dict of
{dir_hash: set(file_hash, ...)} which can be used to map
a .dir file to its file contents.
"""
logger.debug(f"Preparing to collect status from {self.tree.path_info}")
md5s = set(named_cache.scheme_keys(cache.tree.scheme))
logger.debug("Collecting information from local cache...")
local_exists = frozenset(
cache.hashes_exist(md5s, jobs=jobs, name=cache.cache_dir)
)
traverse_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
# For sufficiently large remotes, traverse must be weighted to account
# for performance overhead from large lists/sets.
# From testing with S3, for remotes with 1M+ files, object_exists is
# faster until len(hashes) is at least 10k~100k
if remote_size > self.tree.TRAVERSE_THRESHOLD_SIZE:
traverse_weight = (
traverse_pages * self.tree.TRAVERSE_WEIGHT_MULTIPLIER
)
# This is a performance optimization. We can safely assume that,
# if the resources that we want to fetch are already cached,
# there's no need to check the remote storage for the existence of
# those files.
if download and local_exists == md5s:
remote_exists = local_exists
else:
traverse_weight = traverse_pages
if len(hashes) < traverse_weight:
logger.debug(
"Large remote ('{}' hashes < '{}' traverse weight), "
"using object_exists for remaining hashes".format(
len(hashes), traverse_weight
logger.debug("Collecting information from remote cache...")
remote_exists = set()
dir_md5s = set(named_cache.dir_keys(cache.tree.scheme))
if dir_md5s:
remote_exists.update(
self._indexed_dir_hashes(cache, named_cache, dir_md5s)
)
)
return (
list(indexed_hashes)
+ list(hashes & remote_hashes)
+ self.tree.list_hashes_exists(
hashes - remote_hashes, jobs, name
md5s.difference_update(remote_exists)
if md5s:
remote_exists.update(
self.hashes_exist(
md5s, jobs=jobs, name=str(self.tree.path_info)
)
)
)
return self._make_status(
cache,
named_cache,
show_checksums,
local_exists,
remote_exists,
log_missing,
)
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
remote_hashes = set(
self.tree.list_hashes_traverse(
remote_size, remote_hashes, jobs, name
def _make_status(
self,
cache,
named_cache,
show_checksums,
local_exists,
remote_exists,
log_missing,
):
def make_names(hash_, names):
return {"name": hash_ if show_checksums else " ".join(names)}
dir_status = {}
file_status = {}
dir_contents = {}
for hash_, item in named_cache[cache.tree.scheme].items():
if item.children:
dir_status[hash_] = make_names(hash_, item.names)
dir_contents[hash_] = set()
for child_hash, child in item.children.items():
file_status[child_hash] = make_names(
child_hash, child.names
)
dir_contents[hash_].add(child_hash)
else:
file_status[hash_] = make_names(hash_, item.names)
self._fill_statuses(dir_status, local_exists, remote_exists)
self._fill_statuses(file_status, local_exists, remote_exists)
if log_missing:
self._log_missing_caches(dict(dir_status, **file_status))
return dir_status, file_status, dir_contents
def _indexed_dir_hashes(self, cache, named_cache, dir_md5s):
# Validate our index by verifying all indexed .dir hashes
# still exist on the remote
indexed_dirs = set(self.index.dir_hashes())
indexed_dir_exists = set()
if indexed_dirs:
indexed_dir_exists.update(
self.cache.list_hashes_exists(indexed_dirs)
)
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
if missing_dirs:
logger.debug(
"Remote cache missing indexed .dir hashes '{}', "
"clearing remote index".format(", ".join(missing_dirs))
)
self.index.clear()
# Check if non-indexed (new) dir hashes exist on remote
dir_exists = dir_md5s.intersection(indexed_dir_exists)
dir_exists.update(self.cache.list_hashes_exists(dir_md5s - dir_exists))
# If .dir hash exists on the remote, assume directory contents
# still exists on the remote
for dir_hash in dir_exists:
file_hashes = list(
named_cache.child_keys(cache.tree.scheme, dir_hash)
)
if dir_hash not in self.index:
logger.debug(
"Indexing new .dir '{}' with '{}' nested files".format(
dir_hash, len(file_hashes)
)
)
self.index.update([dir_hash], file_hashes)
yield dir_hash
yield from file_hashes
@staticmethod
def _fill_statuses(hash_info_dir, local_exists, remote_exists):
# Using sets because they are way faster for lookups
local = set(local_exists)
remote = set(remote_exists)
for md5, info in hash_info_dir.items():
status = STATUS_MAP[(md5 in local, md5 in remote)]
info["status"] = status
def _get_plans(self, cache_obj, download, status_info, status):
cache = []
path_infos = []
names = []
hashes = []
missing = []
for md5, info in Tqdm(
status_info.items(), desc="Analysing status", unit="file"
):
if info["status"] == status:
cache.append(cache_obj.tree.hash_to_path_info(md5))
path_infos.append(self.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
from_infos = path_infos
else:
to_infos = path_infos
from_infos = cache
return (from_infos, to_infos, names, hashes), missing
def _process(
self,
cache,
named_cache,
jobs=None,
show_checksums=False,
download=False,
):
logger.debug(
"Preparing to {} '{}'".format(
"download data from" if download else "upload data to",
self.tree.path_info,
)
)
return list(indexed_hashes) + list(hashes & set(remote_hashes))
@classmethod
if download:
func = partial(
_log_exceptions(self.tree.download, "download"),
dir_mode=cache.tree.dir_mode,
file_mode=cache.tree.file_mode,
)
status = STATUS_DELETED
desc = "Downloading"
else:
func = _log_exceptions(self.tree.upload, "upload")
status = STATUS_NEW
desc = "Uploading"
if jobs is None:
jobs = self.tree.JOBS
dir_status, file_status, dir_contents = self._status(
cache,
named_cache,
jobs=jobs,
show_checksums=show_checksums,
download=download,
)
dir_plans, _ = self._get_plans(cache, download, dir_status, status)
file_plans, missing_files = self._get_plans(
cache, download, file_status, status
)
total = len(dir_plans[0]) + len(file_plans[0])
if total == 0:
return 0
with Tqdm(total=total, unit="file", desc=desc) as pbar:
func = pbar.wrap_fn(func)
with ThreadPoolExecutor(max_workers=jobs) as executor:
if download:
from_infos, to_infos, names, _ = (
d + f for d, f in zip(dir_plans, file_plans)
)
fails = sum(
executor.map(func, from_infos, to_infos, names)
)
else:
# for uploads, push files first, and any .dir files last
file_futures = {}
for from_info, to_info, name, hash_ in zip(*file_plans):
file_futures[hash_] = executor.submit(
func, from_info, to_info, name
)
dir_futures = {}
for from_info, to_info, name, dir_hash in zip(*dir_plans):
# 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(
file_futures.values(), dir_futures.values()
)
)
if fails:
if download:
self.index.clear()
raise DownloadError(fails)
raise UploadError(fails)
if not download:
# index successfully pushed dirs
for dir_hash, future in dir_futures.items():
if future.result() == 0:
file_hashes = dir_contents[dir_hash]
logger.debug(
"Indexing pushed dir '{}' with "
"'{}' nested files".format(dir_hash, len(file_hashes))
)
self.index.update([dir_hash], file_hashes)
return len(dir_plans[0]) + len(file_plans[0])
@staticmethod
def _dir_upload(func, futures, from_info, to_info, name):
for future in as_completed(futures):
if future.result():
# do not upload this .dir file if any file in this
# directory failed to upload
logger.debug(
"failed to upload full contents of '{}', "
"aborting .dir file upload".format(name)
)
logger.error(f"failed to upload '{from_info}' to '{to_info}'")
return 1
return func(from_info, to_info, name)
@index_locked
def gc(cls, named_cache, remote, jobs=None):
tree = remote.tree
used = set(named_cache.scheme_keys("local"))
def push(self, cache, named_cache, jobs=None, show_checksums=False):
return self._process(
cache,
named_cache,
jobs=jobs,
show_checksums=show_checksums,
download=False,
)
if tree.scheme != "":
used.update(named_cache.scheme_keys(tree.scheme))
@index_locked
def pull(self, cache, named_cache, jobs=None, show_checksums=False):
ret = self._process(
cache,
named_cache,
jobs=jobs,
show_checksums=show_checksums,
download=True,
)
removed = False
# hashes must be sorted to ensure we always remove .dir files first
for hash_ in sorted(
tree.all(jobs, str(tree.path_info)),
key=tree.is_dir_hash,
reverse=True,
):
if hash_ in used:
continue
path_info = tree.hash_to_path_info(hash_)
if tree.is_dir_hash(hash_):
# backward compatibility
# pylint: disable=protected-access
tree._remove_unpacked_dir(hash_)
tree.remove(path_info)
removed = True
if not self.tree.verify:
with cache.tree.state:
for checksum in named_cache.scheme_keys("local"):
cache_file = cache.tree.hash_to_path_info(checksum)
if cache.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()`)
hash_info = HashInfo(
cache.tree.PARAM_CHECKSUM, checksum
)
cache.tree.state.save(cache_file, hash_info)
if removed and hasattr(remote, "index"):
remote.index.clear()
return removed
return ret
@staticmethod
def _log_missing_caches(hash_info_dict):
missing_caches = [
(md5, info)
for md5, info in hash_info_dict.items()
if info["status"] == STATUS_MISSING
]
if missing_caches:
missing_desc = "\n".join(
"name: {}, md5: {}".format(info["name"], md5)
for md5, info in missing_caches
)
msg = (
"Some of the cache files do not exist neither locally "
"nor on remote. Missing cache files:\n{}".format(missing_desc)
)
logger.warning(msg)
+6 -1
View File
@@ -6,6 +6,7 @@ import threading
from funcy import lchunks
from dvc.state import _connect_sqlite
from dvc.utils.fs import makedirs
logger = logging.getLogger(__name__)
@@ -67,9 +68,12 @@ class RemoteIndex:
VERSION = 1
INDEX_TABLE = "remote_index"
INDEX_TABLE_LAYOUT = "checksum TEXT PRIMARY KEY, dir INTEGER NOT NULL"
INDEX_DIR = "index"
def __init__(self, repo, name, dir_suffix=".dir"):
self.path = os.path.join(repo.index_dir, f"{name}{self.INDEX_SUFFIX}")
self.path = os.path.join(
repo.tmp_dir, self.INDEX_DIR, f"{name}{self.INDEX_SUFFIX}"
)
self.dir_suffix = dir_suffix
self.database = None
@@ -150,6 +154,7 @@ class RemoteIndex:
assert self.cursor is None
empty = not os.path.isfile(self.path)
makedirs(os.path.dirname(self.path), exist_ok=True)
self.database = _connect_sqlite(self.path, {"nolock": 1})
self.cursor = self.database.cursor()
+1 -1
View File
@@ -3,4 +3,4 @@ from .index import RemoteIndexNoop
class LocalRemote(Remote):
INDEX_CLS = RemoteIndexNoop
INDEX_CLS = RemoteIndexNoop # type: ignore[assignment]
+1
View File
@@ -1,3 +1,4 @@
# type: ignore
import logging
import sys
import time
+159 -204
View File
@@ -2,8 +2,10 @@ import logging
import os
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING
from funcy import cached_property, cat, first
from funcy import cached_property, cat
from git import InvalidGitRepositoryError
from dvc.config import Config
from dvc.dvcfile import PIPELINE_FILE, Dvcfile, is_valid_filename
@@ -15,26 +17,47 @@ from dvc.exceptions import (
OutputNotFoundError,
)
from dvc.path_info import PathInfo
from dvc.repo.tree import RepoTree
from dvc.scm import Base
from dvc.scm.base import SCMError
from dvc.tree.repo import RepoTree
from dvc.utils.fs import path_isin
from ..stage.exceptions import StageFileDoesNotExistError, StageNotFound
from ..utils import parse_target
from .graph import check_acyclic, get_pipeline, get_pipelines
from .graph import build_graph, build_outs_graph, get_pipeline, get_pipelines
from .trie import build_outs_trie
if TYPE_CHECKING:
from dvc.tree.base import BaseTree
logger = logging.getLogger(__name__)
@contextmanager
def lock_repo(repo: "Repo"):
# pylint: disable=protected-access
depth = repo._lock_depth
repo._lock_depth += 1
try:
if depth > 0:
yield
else:
with repo.lock, repo.state:
repo._reset()
yield
# Graph cache is no longer valid after we release the repo.lock
repo._reset()
finally:
repo._lock_depth = depth
def locked(f):
@wraps(f)
def wrapper(repo, *args, **kwargs):
with repo.lock, repo.state:
# pylint: disable=protected-access
repo._reset()
ret = f(repo, *args, **kwargs)
# Our graph cache is no longer valid after we release the repo.lock
repo._reset()
return ret
with lock_repo(repo):
return f(repo, *args, **kwargs)
return wrapper
@@ -42,109 +65,149 @@ 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.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.move import move
from dvc.repo.run import run
from dvc.repo.gc import gc
from dvc.repo.get import get as _get
from dvc.repo.get_url import get_url as _get_url
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.install import install
from dvc.repo.ls import ls as _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.gc import gc
from dvc.repo.commit import commit
from dvc.repo.diff import diff
from dvc.repo.brancher import brancher
from dvc.repo.get import get
from dvc.repo.get_url import get_url
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
ls = staticmethod(_ls)
get = staticmethod(_get)
get_url = staticmethod(_get_url)
def _get_repo_dirs(
self,
root_dir: str = None,
scm: Base = None,
rev: str = None,
uninitialized: bool = False,
):
assert bool(scm) == bool(rev)
from dvc.scm import SCM
from dvc.scm.git import Git
from dvc.utils.fs import makedirs
dvc_dir = None
tmp_dir = None
try:
tree = scm.get_tree(rev) if isinstance(scm, Git) and rev else None
root_dir = self.find_root(root_dir, tree)
dvc_dir = os.path.join(root_dir, self.DVC_DIR)
tmp_dir = os.path.join(dvc_dir, "tmp")
makedirs(tmp_dir, exist_ok=True)
except NotDvcRepoError:
if not uninitialized:
raise
try:
scm = SCM(root_dir or os.curdir)
except (SCMError, InvalidGitRepositoryError):
scm = SCM(os.curdir, no_scm=True)
assert isinstance(scm, Base)
root_dir = scm.root_dir
return root_dir, dvc_dir, tmp_dir
def __init__(
self,
root_dir=None,
scm=None,
rev=None,
subrepos=False,
uninitialized=False,
):
from dvc.cache import Cache
from dvc.data_cloud import DataCloud
from dvc.lock import LockNoop, 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.tree.local import LocalTree
from dvc.utils.fs import makedirs
from dvc.repo.plots import Plots
from dvc.stage.cache import StageCache
from dvc.state import State, StateNoop
from dvc.tree.local import LocalTree
if scm:
tree = scm.get_tree(rev)
self.root_dir = self.find_root(root_dir, tree)
self.scm = scm
self.tree = scm.get_tree(
rev, use_dvcignore=True, dvcignore_root=self.root_dir
)
self.state = StateNoop()
else:
root_dir = self.find_root(root_dir)
self.root_dir = os.path.abspath(os.path.realpath(root_dir))
self.tree = LocalTree(
self,
{"url": self.root_dir},
use_dvcignore=True,
dvcignore_root=self.root_dir,
)
self.dvc_dir = os.path.join(self.root_dir, self.DVC_DIR)
self.config = Config(self.dvc_dir, tree=self.tree)
if not scm:
no_scm = self.config["core"].get("no_scm", False)
self.scm = SCM(self.root_dir, no_scm=no_scm)
self.tmp_dir = os.path.join(self.dvc_dir, "tmp")
self.index_dir = os.path.join(self.tmp_dir, "index")
makedirs(self.index_dir, exist_ok=True)
hardlink_lock = self.config["core"].get("hardlink_lock", False)
self.lock = make_lock(
os.path.join(self.tmp_dir, "lock"),
tmp_dir=self.tmp_dir,
hardlink_lock=hardlink_lock,
friendly=True,
self.root_dir, self.dvc_dir, self.tmp_dir = self._get_repo_dirs(
root_dir=root_dir, scm=scm, rev=rev, uninitialized=uninitialized
)
tree_kwargs = {"use_dvcignore": True, "dvcignore_root": self.root_dir}
if scm:
self.tree = scm.get_tree(rev, **tree_kwargs)
else:
self.tree = LocalTree(self, {"url": self.root_dir}, **tree_kwargs)
self.config = Config(self.dvc_dir, tree=self.tree)
self._scm = scm
# used by RepoTree to determine if it should traverse subrepos
self.subrepos = subrepos
self.cache = Cache(self)
self.cloud = DataCloud(self)
if not scm:
if scm or not self.dvc_dir:
self.lock = LockNoop()
self.state = StateNoop()
else:
self.lock = make_lock(
os.path.join(self.tmp_dir, "lock"),
tmp_dir=self.tmp_dir,
hardlink_lock=self.config["core"].get("hardlink_lock", False),
friendly=True,
)
# NOTE: storing state and link_state in the repository itself to
# avoid any possible state corruption in 'shared cache dir'
# scenario.
self.state = State(self.cache.local)
self.state = State(self)
self.stage_cache = StageCache(self)
self.stage_cache = StageCache(self)
try:
self.experiments = Experiments(self)
except NotImplementedError:
self.experiments = None
self._ignore()
self.metrics = Metrics(self)
self.plots = Plots(self)
self.params = Params(self)
self._lock_depth = 0
try:
self.experiments = Experiments(self)
except NotImplementedError:
self.experiments = None
@cached_property
def scm(self):
from dvc.scm import SCM
self._ignore()
no_scm = self.config["core"].get("no_scm", False)
return self._scm if self._scm else SCM(self.root_dir, no_scm=no_scm)
@property
def tree(self):
def tree(self) -> "BaseTree":
return self._tree
@tree.setter
def tree(self, tree):
def tree(self, tree: "BaseTree"):
self._tree = tree
# Our graph cache is no longer valid, as it was based on the previous
# tree.
@@ -154,7 +217,7 @@ class Repo:
return f"{self.__class__.__name__}: '{self.root_dir}'"
@classmethod
def find_root(cls, root=None, tree=None):
def find_root(cls, root=None, tree=None) -> str:
root_dir = os.path.realpath(root or os.curdir)
if tree:
@@ -241,7 +304,7 @@ class Repo:
#
# [1] https://github.com/iterative/dvc/issues/2671
if not getattr(self, "_skip_graph_checks", False):
self._collect_graph(self.stages + new_stages)
build_graph(self.stages + new_stages)
def _collect_inside(self, path, graph):
import networkx as nx
@@ -400,113 +463,17 @@ class Repo:
return cache
def _collect_graph(self, stages):
"""Generate a graph by using the given stages on the given directory
The nodes of the graph are the stage's path relative to the root.
Edges are created when the output of one stage is used as a
dependency in other stage.
The direction of the edges goes from the stage to its dependency:
For example, running the following:
$ dvc run -o A "echo A > A"
$ dvc run -d A -o B "echo B > B"
$ dvc run -d B -o C "echo C > C"
Will create the following graph:
ancestors <--
|
C.dvc -> B.dvc -> A.dvc
| |
| --> descendants
|
------- pipeline ------>
|
v
(weakly connected components)
Args:
stages (list): used to build a graph, if None given, collect stages
in the repository.
Raises:
OutputDuplicationError: two outputs with the same path
StagePathAsOutputError: stage inside an output directory
OverlappingOutputPathsError: output inside output directory
CyclicGraphError: resulting graph has cycles
"""
import networkx as nx
from pygtrie import Trie
from dvc.exceptions import (
OutputDuplicationError,
StagePathAsOutputError,
OverlappingOutputPathsError,
)
G = nx.DiGraph()
stages = stages or self.stages
outs = Trie() # Use trie to efficiently find overlapping outs and deps
for stage in filter(bool, stages): # bug? not using it later
for out in stage.outs:
out_key = out.path_info.parts
# Check for dup outs
if out_key in outs:
dup_stages = [stage, outs[out_key].stage]
raise OutputDuplicationError(str(out), dup_stages)
# Check for overlapping outs
if outs.has_subtrie(out_key):
parent = out
overlapping = first(outs.values(prefix=out_key))
else:
parent = outs.shortest_prefix(out_key).value
overlapping = out
if parent and overlapping:
msg = (
"Paths for outs:\n'{}'('{}')\n'{}'('{}')\n"
"overlap. To avoid unpredictable behaviour, "
"rerun command with non overlapping outs paths."
).format(
str(parent),
parent.stage.addressing,
str(overlapping),
overlapping.stage.addressing,
)
raise OverlappingOutputPathsError(parent, overlapping, msg)
outs[out_key] = out
for stage in stages:
out = outs.shortest_prefix(PathInfo(stage.path).parts).value
if out:
raise StagePathAsOutputError(stage, str(out))
# Building graph
G.add_nodes_from(stages)
for stage in stages:
for dep in stage.deps:
if dep.path_info is None:
continue
dep_key = dep.path_info.parts
overlapping = [n.value for n in outs.prefixes(dep_key)]
if outs.has_subtrie(dep_key):
overlapping.extend(outs.values(prefix=dep_key))
G.add_edges_from((stage, out.stage) for out in overlapping)
check_acyclic(G)
return G
@cached_property
def outs_trie(self):
return build_outs_trie(self.stages)
@cached_property
def graph(self):
return self._collect_graph(self.stages)
return build_graph(self.stages, self.outs_trie)
@cached_property
def outs_graph(self):
return build_outs_graph(self.graph, self.outs_trie)
@cached_property
def pipelines(self):
@@ -525,12 +492,6 @@ class Repo:
"""
return self._collect_stages()
@cached_property
def plot_templates(self):
from .plots.template import PlotTemplates
return PlotTemplates(self.dvc_dir)
def _collect_stages(self):
stages = []
outs = set()
@@ -580,19 +541,20 @@ class Repo:
path_parts = os.path.normpath(path).split(os.path.sep)
return self.DVC_DIR in path_parts
@cached_property
def repo_tree(self):
return RepoTree(self, subrepos=self.subrepos, fetch=True)
@contextmanager
def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
"""Opens a specified resource as a file descriptor"""
tree = RepoTree(self, stream=True)
path = os.path.join(self.root_dir, path)
tree = RepoTree(self, stream=True, subrepos=True)
path = PathInfo(self.root_dir) / path
try:
with self.state:
with tree.open(
os.path.join(self.root_dir, path),
mode=mode,
encoding=encoding,
remote=remote,
path, mode=mode, encoding=encoding, remote=remote,
) as fobj:
yield fobj
except FileNotFoundError as exc:
@@ -603,16 +565,9 @@ class Repo:
def close(self):
self.scm.close()
@locked
def checkout(self, *args, **kwargs):
return self._checkout(*args, **kwargs)
@locked
def fetch(self, *args, **kwargs):
return self._fetch(*args, **kwargs)
def _reset(self):
self.__dict__.pop("outs_trie", None)
self.__dict__.pop("outs_graph", None)
self.__dict__.pop("graph", None)
self.__dict__.pop("stages", None)
self.__dict__.pop("pipelines", None)
self.tree.__dict__.pop("dvcignore", None)
+64 -25
View File
@@ -6,6 +6,7 @@ import colorama
from dvc.dvcfile import Dvcfile, is_dvc_file
from ..exceptions import (
CacheLinkError,
OutputDuplicationError,
OverlappingOutputPathsError,
RecursiveAddingWhileUsingFilename,
@@ -22,7 +23,7 @@ logger = logging.getLogger(__name__)
@locked
@scm_context
def add(
repo, targets, recursive=False, no_commit=False, fname=None, external=False
repo, targets, recursive=False, no_commit=False, fname=None, **kwargs,
):
if recursive and fname:
raise RecursiveAddingWhileUsingFilename()
@@ -30,6 +31,7 @@ def add(
if isinstance(targets, str):
targets = [targets]
link_failures = []
stages_list = []
num_targets = len(targets)
with Tqdm(total=num_targets, desc="Add", unit="file", leave=True) as pbar:
@@ -55,7 +57,7 @@ def add(
)
stages = _create_stages(
repo, sub_targets, fname, pbar=pbar, external=external
repo, sub_targets, fname, pbar=pbar, **kwargs,
)
try:
@@ -79,33 +81,56 @@ def add(
exc.output, set(exc.stages) - set(stages)
)
with Tqdm(
total=len(stages),
desc="Processing",
unit="file",
disable=len(stages) == 1,
) as pbar_stages:
for stage in stages:
try:
stage.save()
except OutputDoesNotExistError:
pbar.n -= 1
raise
if not no_commit:
stage.commit()
Dvcfile(repo, stage.path).dump(stage)
pbar_stages.update()
link_failures.extend(
_process_stages(repo, stages, no_commit, pbar)
)
stages_list += stages
if num_targets == 1: # restore bar format for stats
pbar.bar_format = pbar.BAR_FMT_DEFAULT
if link_failures:
msg = (
"Some targets could not be linked from cache to workspace.\n{}\n"
"To re-link these targets, reconfigure cache types and then run:\n"
"\n\tdvc checkout {}"
).format(
CacheLinkError.SUPPORT_LINK,
" ".join([str(stage.relpath) for stage in link_failures]),
)
logger.warning(msg)
return stages_list
def _process_stages(repo, stages, no_commit, pbar):
link_failures = []
with Tqdm(
total=len(stages),
desc="Processing",
unit="file",
disable=len(stages) == 1,
) as pbar_stages:
for stage in stages:
try:
stage.save()
except OutputDoesNotExistError:
pbar.n -= 1
raise
try:
if not no_commit:
stage.commit()
except CacheLinkError:
link_failures.append(stage)
Dvcfile(repo, stage.path).dump(stage)
pbar_stages.update()
return link_failures
def _find_all_targets(repo, target, recursive):
if os.path.isdir(target) and recursive:
return [
@@ -124,15 +149,27 @@ def _find_all_targets(repo, target, recursive):
return [target]
def _create_stages(repo, targets, fname, pbar=None, external=False):
def _create_stages(
repo, targets, fname, pbar=None, external=False, glob=False, desc=None,
):
from glob import iglob
from dvc.stage import Stage, create_stage
stages = []
if glob:
expanded_targets = [
exp_target
for target in targets
for exp_target in iglob(target, recursive=True)
]
else:
expanded_targets = targets
stages = []
for out in Tqdm(
targets,
expanded_targets,
desc="Creating DVC-files",
disable=len(targets) < LARGE_DIR_SIZE,
disable=len(expanded_targets) < LARGE_DIR_SIZE,
unit="file",
):
path, wdir, out = resolve_paths(repo, out)
@@ -146,6 +183,8 @@ def _create_stages(repo, targets, fname, pbar=None, external=False):
)
if stage:
Dvcfile(repo, stage.path).remove()
if desc:
stage.outs[0].desc = desc
repo._reset() # pylint: disable=protected-access
+11 -2
View File
@@ -4,7 +4,12 @@ from dvc.tree.local import LocalTree
def brancher( # noqa: E302
self, revs=None, all_branches=False, all_tags=False, all_commits=False
self,
revs=None,
all_branches=False,
all_tags=False,
all_commits=False,
sha_only=False,
):
"""Generator that iterates over specified revisions.
@@ -13,6 +18,7 @@ def brancher( # noqa: E302
all_branches (bool): iterate over all available branches.
all_commits (bool): iterate over all commits.
all_tags (bool): iterate over all available tags.
sha_only (bool): only return git SHA for a revision.
Yields:
str: the display name for the currently selected tree, it could be:
@@ -53,6 +59,9 @@ def brancher( # noqa: E302
# ignore revs that don't contain repo root
# (i.e. revs from before a subdir=True repo was init'ed)
if self.tree.exists(self.root_dir):
yield ", ".join(names)
if sha_only:
yield sha
else:
yield ", ".join(names)
finally:
self.tree = saved_tree
+8 -1
View File
@@ -9,6 +9,8 @@ from dvc.exceptions import (
from dvc.progress import Tqdm
from dvc.utils import relpath
from . import locked
logger = logging.getLogger(__name__)
@@ -36,13 +38,16 @@ def get_all_files_numbers(pairs):
)
def _checkout(
@locked
def checkout(
self,
targets=None,
with_deps=False,
force=False,
relink=False,
recursive=False,
allow_missing=False,
**kwargs,
):
from dvc.stage.exceptions import (
StageFileBadNameError,
@@ -93,6 +98,8 @@ def _checkout(
progress_callback=pbar.update_msg,
relink=relink,
filter_info=filter_info,
allow_missing=allow_missing,
**kwargs,
)
for key, items in result.items():
stats[key].extend(_fspath_dir(path) for path in items)
+87
View File
@@ -0,0 +1,87 @@
import logging
import os
from typing import Callable, Iterable, Set, Tuple
from dvc.output.base import BaseOutput
from dvc.path_info import PathInfo
from dvc.repo import Repo
from dvc.tree.repo import RepoTree
from dvc.types import DvcPath
logger = logging.getLogger(__name__)
FilterFn = Callable[[BaseOutput], bool]
Outputs = Set[BaseOutput]
DvcPaths = Set[DvcPath]
def _collect_outs(
repo: Repo, output_filter: FilterFn = None, deps: bool = False,
) -> Outputs:
outs = {
out
for stage in repo.stages
for out in (stage.deps if deps else stage.outs)
}
return set(filter(output_filter, outs)) if output_filter else outs
def _collect_paths(
repo: Repo,
targets: Iterable[str],
recursive: bool = False,
rev: str = None,
):
path_infos = {PathInfo(os.path.abspath(target)) for target in targets}
tree = RepoTree(repo)
target_infos = set()
for path_info in path_infos:
if recursive and tree.isdir(path_info):
target_infos.update(set(tree.walk_files(path_info)))
if not tree.isfile(path_info):
if not recursive:
logger.warning(
"'%s' was not found at: '%s'.", path_info, rev,
)
continue
target_infos.add(path_info)
return target_infos
def _filter_duplicates(
outs: Outputs, path_infos: DvcPaths
) -> Tuple[Outputs, DvcPaths]:
res_outs: Outputs = set()
res_infos = set(path_infos)
for out in outs:
if out.path_info in path_infos:
res_outs.add(out)
res_infos.remove(out.path_info)
return res_outs, res_infos
def collect(
repo: Repo,
deps: bool = False,
targets: Iterable[str] = None,
output_filter: FilterFn = None,
rev: str = None,
recursive: bool = False,
) -> Tuple[Outputs, DvcPaths]:
assert targets or output_filter
outs: Outputs = _collect_outs(repo, output_filter=output_filter, deps=deps)
if not targets:
path_infos: DvcPaths = set()
return outs, path_infos
target_infos = _collect_paths(repo, targets, recursive=recursive, rev=rev)
return _filter_duplicates(outs, target_infos)
+1 -1
View File
@@ -45,5 +45,5 @@ def commit(self, target, with_deps=False, recursive=False, force=False):
stage.save()
stage.commit()
Dvcfile(self, stage.path).dump(stage)
Dvcfile(self, stage.path).dump(stage, update_pipeline=False)
return stages
+89 -50
View File
@@ -1,7 +1,11 @@
import logging
import os
from dvc.repo import locked
from dvc.tree.local import LocalTree
from dvc.tree.repo import RepoTree
logger = logging.getLogger(__name__)
@locked
@@ -14,64 +18,32 @@ def diff(self, a_rev="HEAD", b_rev=None):
`dvc diff` would be the same as `dvc diff HEAD`.
"""
def _paths_checksums():
"""
A dictionary of checksums addressed by relpaths collected from
the current tree outputs.
To help distinguish between a directory and a file output,
the former one will come with a trailing slash in the path:
directory: "data/"
file: "data"
"""
def _to_path(output):
return (
str(output)
if not output.is_dir_checksum
else os.path.join(str(output), "")
)
on_working_tree = isinstance(self.tree, LocalTree)
def _to_checksum(output):
if on_working_tree:
return self.cache.local.tree.get_hash(output.path_info)
return output.checksum
def _exists(output):
if on_working_tree:
return output.exists
return True
return {
_to_path(output): _to_checksum(output)
for stage in self.stages
for output in stage.outs
if _exists(output)
}
if self.scm.no_commits:
return {}
working_tree = self.tree
a_tree = self.scm.get_tree(a_rev)
b_tree = self.scm.get_tree(b_rev) if b_rev else working_tree
b_rev = b_rev if b_rev else "workspace"
results = {}
for rev in self.brancher(revs=[a_rev, b_rev]):
if rev == "workspace" and rev != b_rev:
# brancher always returns workspace, but we only need to compute
# workspace paths/checksums if b_rev was None
continue
results[rev] = _paths_checksums(self)
try:
self.tree = a_tree
old = _paths_checksums()
self.tree = b_tree
new = _paths_checksums()
finally:
self.tree = working_tree
old = results[a_rev]
new = results[b_rev]
# Compare paths between the old and new tree.
# set() efficiently converts dict keys to a set
added = sorted(set(new) - set(old))
deleted = sorted(set(old) - set(new))
deleted_or_missing = set(old) - set(new)
if b_rev == "workspace":
# missing status is only applicable when diffing local workspace
# against a commit
missing = sorted(_filter_missing(self, deleted_or_missing))
else:
missing = []
deleted = sorted(deleted_or_missing - set(missing))
modified = sorted(set(old) & set(new))
ret = {
@@ -82,6 +54,73 @@ def diff(self, a_rev="HEAD", b_rev=None):
for path in modified
if old[path] != new[path]
],
"not in cache": [
{"path": path, "hash": old[path]} for path in missing
],
}
return ret if any(ret.values()) else {}
def _paths_checksums(repo):
"""
A dictionary of checksums addressed by relpaths collected from
the current tree outputs.
To help distinguish between a directory and a file output,
the former one will come with a trailing slash in the path:
directory: "data/"
file: "data"
"""
return dict(_output_paths(repo))
def _output_paths(repo):
repo_tree = RepoTree(repo, stream=True)
on_working_tree = isinstance(repo.tree, LocalTree)
def _exists(output):
if on_working_tree:
return output.exists
return True
def _to_path(output):
return (
str(output)
if not output.is_dir_checksum
else os.path.join(str(output), "")
)
def _to_checksum(output):
if on_working_tree:
return repo.cache.local.tree.get_hash(output.path_info).value
return output.hash_info.value
for stage in repo.stages:
for output in stage.outs:
if _exists(output):
yield _to_path(output), _to_checksum(output)
if output.is_dir_checksum:
yield from _dir_output_paths(repo_tree, output)
def _dir_output_paths(repo_tree, output):
from dvc.config import NoRemoteError
try:
for fname in repo_tree.walk_files(output.path_info):
yield str(fname), repo_tree.get_file_hash(fname).value
except NoRemoteError:
logger.warning("dir cache entry for '%s' is missing", output)
def _filter_missing(repo, paths):
repo_tree = RepoTree(repo, stream=True)
for path in paths:
metadata = repo_tree.metadata(path)
if metadata.is_dvc:
out = metadata.outs[0]
if out.status().get(str(out)) == "not in cache":
yield path
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
import logging
import os
import pickle
from tempfile import TemporaryDirectory
from typing import Iterable, Optional
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:
branch: Existing git branch for this experiment.
rev: Git revision to be checked out for this experiment, defaults to
branch, baseline_rev in that order.
repro_args: Args to be passed into reproduce.
repro_kwargs: Keyword args to be passed into reproduce.
"""
def __init__(
self,
baseline_rev: str,
branch: Optional[str] = None,
rev: Optional[str] = None,
**kwargs,
):
self.baseline_rev = baseline_rev
self._rev = rev
self.branch = branch
self.repro_args = kwargs.pop("repro_args", [])
self.repro_kwargs = kwargs.pop("repro_kwargs", {})
self.tmp_dir: Optional[TemporaryDirectory] = None
@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):
dpath = os.path.dirname(path)
if tree:
open_func = tree.open
tree.makedirs(dpath)
else:
from dvc.utils.fs import makedirs
open_func = open
makedirs(dpath, exist_ok=True)
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"]
@staticmethod
def collect_files(tree: BaseTree, repo_tree: RepoTree):
raise NotImplementedError
class LocalExecutor(ExperimentExecutor):
"""Local machine experiment executor."""
def __init__(
self, checkpoint_reset: Optional[bool] = False, **kwargs,
):
from dvc.repo import Repo
dvc_dir = kwargs.pop("dvc_dir")
cache_dir = kwargs.pop("cache_dir")
super().__init__(**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,
self.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
self.checkpoint_reset = checkpoint_reset
self.checkpoint = False
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
@property
def rev(self):
return self._rev if self._rev else self.baseline_rev
@staticmethod
def reproduce(dvc_dir, cwd=None, **kwargs):
"""Run dvc repro and return the result."""
from dvc.repo import Repo
unchanged = []
def filter_pipeline(stages):
unchanged.extend(
[stage for stage in stages if isinstance(stage, PipelineStage)]
)
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)
# NOTE: for checkpoint experiments we handle persist outs slightly
# differently than normal:
#
# - checkpoint out may not yet exist if this is the first time this
# experiment has been run, this is not an error condition for
# experiments
# - at the start of a repro run, we need to remove the persist out
# and restore it to its last known (committed) state (which may
# be removed/does not yet exist) so that our executor workspace
# is not polluted with the (persistent) out from an unrelated
# experiment run
dvc.checkout(force=True, quiet=True)
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 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()
+51
View File
@@ -0,0 +1,51 @@
import logging
from typing import Optional
from dvc.repo import locked
logger = logging.getLogger(__name__)
@locked
def gc(
repo,
all_branches: Optional[bool] = False,
all_tags: Optional[bool] = False,
all_commits: Optional[bool] = False,
workspace: Optional[bool] = False,
queued: Optional[bool] = False,
):
keep_revs = set(
repo.brancher(
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
sha_only=True,
)
)
if workspace:
keep_revs.add(repo.scm.get_rev())
if not keep_revs:
return 0
delete_branches = []
for exp_branch in repo.experiments.scm.list_branches():
m = repo.experiments.BRANCH_RE.match(exp_branch)
if m:
rev = repo.scm.resolve_rev(m.group("baseline_rev"))
if rev not in keep_revs:
delete_branches.append(exp_branch)
if delete_branches:
repo.experiments.scm.repo.delete_head(*delete_branches, force=True)
removed = len(delete_branches)
delete_stashes = []
for _, entry in repo.experiments.stash_revs.items():
if not queued or entry.baseline_rev not in keep_revs:
delete_stashes.append(entry.index)
for index in sorted(delete_stashes, reverse=True):
repo.experiments.scm.repo.git.stash("drop", index)
removed += len(delete_stashes)
return removed
+68
View File
@@ -0,0 +1,68 @@
import logging
from typing import Iterable, Optional
from dvc.exceptions import InvalidArgumentError
from dvc.repo import locked
from dvc.repo.experiments import UnchangedExperimentError
logger = logging.getLogger(__name__)
def _parse_params(path_params: Iterable):
from ruamel.yaml import YAMLError
from dvc.dependency.param import ParamsDependency
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] = params
return ret
@locked
def run(
repo,
target: Optional[str] = None,
params: Optional[Iterable] = None,
run_all: Optional[bool] = False,
jobs: Optional[int] = 1,
**kwargs,
) -> dict:
"""Reproduce the specified target as an experiment.
Accepts the same additional kwargs as Repo.reproduce.
Returns a dict mapping new experiment SHAs to the results
of `repro` for that experiment.
"""
if run_all:
return repo.experiments.reproduce_queued(jobs=jobs)
if params:
params = _parse_params(params)
else:
params = []
try:
return repo.experiments.reproduce_one(
target=target, params=params, **kwargs
)
except UnchangedExperimentError:
# If experiment contains no changes, just run regular repro
kwargs.pop("queue", None)
kwargs.pop("checkpoint_resume", None)
return {None: repo.reproduce(target=target, **kwargs)}
+84 -21
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,56 +9,119 @@ 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, rev, 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]):
configs = _collect_configs(repo)
for rev in repo.brancher(revs=[rev]):
if rev == "workspace":
res["timestamp"] = None
else:
commit = repo.scm.resolve_commit(rev)
res["timestamp"] = datetime.fromtimestamp(commit.committed_date)
configs = _collect_configs(repo, rev=rev)
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, rev, 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
def _collect_checkpoint_experiment(res, repo, branch, baseline, **kwargs):
exp_rev = repo.scm.resolve_rev(branch)
prev = None
for rev in repo.scm.branch_revs(exp_rev, baseline):
exp = {"checkpoint_tip": exp_rev}
if prev:
res[prev]["checkpoint_parent"] = rev
if rev in res:
res[rev].update(exp)
res.move_to_end(rev)
else:
exp.update(_collect_experiment(repo, rev, **kwargs))
res[rev] = exp
prev = rev
res[prev]["checkpoint_parent"] = baseline
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,
all_commits=all_commits,
sha_only=True,
)
)
for rev in revs:
res[rev]["baseline"] = _collect_experiment(repo, rev)
res[rev]["baseline"] = _collect_experiment(
repo, rev, sha_only=sha_only
)
for exp_branch in repo.experiments.scm.list_branches():
m = re.match(EXP_RE, exp_branch)
# collect reproduced experiments
for head in sorted(
repo.experiments.scm.repo.heads,
key=lambda h: h.commit.committed_date,
reverse=True,
):
exp_branch = head.name
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():
experiment = _collect_experiment(
repo.experiments.exp_dvc, exp_branch
)
res[rev][exp_rev] = experiment
if m.group("checkpoint"):
_collect_checkpoint_experiment(
res[rev], repo.experiments.exp_dvc, exp_branch, rev
)
else:
exp_rev = repo.experiments.scm.resolve_rev(exp_branch)
experiment = _collect_experiment(
repo.experiments.exp_dvc, exp_branch
)
res[rev][exp_rev] = experiment
# collect queued (not yet reproduced) experiments
for stash_rev, entry in repo.experiments.stash_revs.items():
if entry.baseline_rev in revs:
with repo.experiments.chdir():
experiment = _collect_experiment(
repo.experiments.exp_dvc, stash_rev, stash=True
)
res[entry.baseline_rev][stash_rev] = experiment
return res
+14 -10
View File
@@ -4,10 +4,13 @@ from dvc.config import NoRemoteError
from dvc.exceptions import DownloadError
from dvc.scm.base import CloneError
from . import locked
logger = logging.getLogger(__name__)
def _fetch(
@locked
def fetch(
self,
targets=None,
jobs=None,
@@ -33,8 +36,6 @@ def _fetch(
remote is configured
"""
used_run_cache = self.stage_cache.pull(remote) if run_cache else []
if isinstance(targets, str):
targets = [targets]
@@ -48,13 +49,14 @@ def _fetch(
remote=remote,
jobs=jobs,
recursive=recursive,
used_run_cache=used_run_cache,
)
downloaded = 0
failed = 0
try:
if run_cache:
self.stage_cache.pull(remote)
downloaded += self.cloud.pull(
used, jobs, remote=remote, show_checksums=show_checksums,
)
@@ -72,19 +74,21 @@ def _fetch(
if failed:
raise DownloadError(failed)
return downloaded + len(used_run_cache)
return downloaded
def _fetch_external(self, repo_url, repo_rev, files, jobs):
from dvc.external_repo import external_repo
failed, downloaded = 0, 0
cache = self.cache.local
try:
with external_repo(repo_url, repo_rev) as repo:
with repo.use_cache(self.cache.local):
d, f, _ = repo.fetch_external(files, jobs=jobs)
downloaded += d
failed += f
with external_repo(
repo_url, repo_rev, cache_dir=cache.cache_dir
) as repo:
d, f, _ = repo.fetch_external(files, jobs=jobs)
downloaded += d
failed += f
except CloneError:
failed += 1
logger.exception(
+1 -1
View File
@@ -8,7 +8,7 @@ def _set(repo, target, frozen):
path, name = parse_target(target)
stage = repo.get_stage(path, name)
stage.frozen = frozen
stage.dvcfile.dump(stage, update_pipeline=True)
stage.dvcfile.dump(stage, update_lock=False)
return stage
+14 -25
View File
@@ -3,19 +3,12 @@ import logging
from dvc.cache import NamedCache
from dvc.exceptions import InvalidArgumentError
from ..scheme import Schemes
from . import locked
logger = logging.getLogger(__name__)
def _do_gc(typ, remote, clist, jobs=None):
from dvc.remote.base import Remote
removed = Remote.gc(clist, remote, jobs=jobs)
if not removed:
logger.info(f"No unused '{typ}' cache to remove.")
def _raise_error_if_all_disabled(**kwargs):
if not any(kwargs.values()):
raise InvalidArgumentError(
@@ -50,6 +43,7 @@ def gc(
)
from contextlib import ExitStack
from dvc.repo import Repo
if not repos:
@@ -75,23 +69,18 @@ def gc(
)
)
# treat caches as remotes for garbage collection
_do_gc("local", self.cache.local, used, jobs)
for scheme, cache in self.cache.by_scheme():
if not cache:
continue
if self.cache.s3:
_do_gc("s3", self.cache.s3, used, jobs)
removed = cache.gc(set(used.scheme_keys(scheme)), jobs=jobs)
if not removed:
logger.info(f"No unused '{scheme}' cache to remove.")
if self.cache.gs:
_do_gc("gs", self.cache.gs, used, jobs)
if not cloud:
return
if self.cache.ssh:
_do_gc("ssh", self.cache.ssh, used, jobs)
if self.cache.hdfs:
_do_gc("hdfs", self.cache.hdfs, used, jobs)
if self.cache.azure:
_do_gc("azure", self.cache.azure, used, jobs)
if cloud:
_do_gc("remote", self.cloud.get_remote(remote, "gc -c"), used, jobs)
remote = self.cloud.get_remote(remote, "gc -c")
removed = remote.gc(set(used.scheme_keys(Schemes.LOCAL)), jobs=jobs)
if not removed:
logger.info("No unused cache to remove from remote.")
+15 -16
View File
@@ -18,10 +18,9 @@ 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)
@@ -35,21 +34,21 @@ def get(url, path, out=None, rev=None):
# and won't work with reflink/hardlink.
dpath = os.path.dirname(os.path.abspath(out))
tmp_dir = os.path.join(dpath, "." + str(shortuuid.uuid()))
try:
with external_repo(url=url, rev=rev) as repo:
if hasattr(repo, "cache"):
repo.cache.local.cache_dir = tmp_dir
# Try any links possible to avoid data duplication.
#
# Not using symlink, because we need to remove cache after we
# are done, and to make that work we would have to copy data
# over anyway before removing the cache, so we might just copy
# it right away.
#
# Also, we can't use theoretical "move" link type here, because
# the same cache file might be used a few times in a directory.
repo.cache.local.cache_types = ["reflink", "hardlink", "copy"]
# Try any links possible to avoid data duplication.
#
# Not using symlink, because we need to remove cache after we
# are done, and to make that work we would have to copy data
# over anyway before removing the cache, so we might just copy
# it right away.
#
# Also, we can't use theoretical "move" link type here, because
# the same cache file might be used a few times in a directory.
cache_types = ["reflink", "hardlink", "copy"]
try:
with external_repo(
url=url, rev=rev, cache_dir=tmp_dir, cache_types=cache_types
) as repo:
repo.get_external(path, out)
finally:
remove(tmp_dir)
+2 -2
View File
@@ -5,7 +5,6 @@ import dvc.output as output
from dvc.utils import resolve_output
@staticmethod
def get_url(url, out=None):
out = resolve_output(url, out)
@@ -16,6 +15,7 @@ def get_url(url, out=None):
(dep,) = dependency.loads_from(None, [url])
(out,) = output.loads_from(None, [out], use_cache=False)
dep.save()
if not dep.exists:
raise dep.DoesNotExistError(dep)
dep.download(out)
out.save()
+95
View File
@@ -1,5 +1,6 @@
def check_acyclic(graph):
import networkx as nx
from dvc.exceptions import CyclicGraphError
try:
@@ -25,3 +26,97 @@ def get_pipelines(G):
import networkx as nx
return [G.subgraph(c).copy() for c in nx.weakly_connected_components(G)]
def build_graph(stages, outs_trie=None):
"""Generate a graph by using the given stages on the given directory
The nodes of the graph are the stage's path relative to the root.
Edges are created when the output of one stage is used as a
dependency in other stage.
The direction of the edges goes from the stage to its dependency:
For example, running the following:
$ dvc run -o A "echo A > A"
$ dvc run -d A -o B "echo B > B"
$ dvc run -d B -o C "echo C > C"
Will create the following graph:
ancestors <--
|
C.dvc -> B.dvc -> A.dvc
| |
| --> descendants
|
------- pipeline ------>
|
v
(weakly connected components)
Args:
stages (list): used to build a graph from
Raises:
OutputDuplicationError: two outputs with the same path
StagePathAsOutputError: stage inside an output directory
OverlappingOutputPathsError: output inside output directory
CyclicGraphError: resulting graph has cycles
"""
import networkx as nx
from dvc.exceptions import StagePathAsOutputError
from ..path_info import PathInfo
from .trie import build_outs_trie
G = nx.DiGraph()
# Use trie to efficiently find overlapping outs and deps
outs_trie = outs_trie or build_outs_trie(stages)
for stage in stages:
out = outs_trie.shortest_prefix(PathInfo(stage.path).parts).value
if out:
raise StagePathAsOutputError(stage, str(out))
# Building graph
G.add_nodes_from(stages)
for stage in stages:
for dep in stage.deps:
if dep.path_info is None:
continue
dep_key = dep.path_info.parts
overlapping = [n.value for n in outs_trie.prefixes(dep_key)]
if outs_trie.has_subtrie(dep_key):
overlapping.extend(outs_trie.values(prefix=dep_key))
G.add_edges_from((stage, out.stage) for out in overlapping)
check_acyclic(G)
return G
# NOTE: using stage graph instead of just list of stages to make sure that it
# has already passed all the sanity checks like cycles/overlapping outputs and
# so on.
def build_outs_graph(graph, outs_trie):
import networkx as nx
G = nx.DiGraph()
G.add_nodes_from(outs_trie.values())
for stage in graph.nodes():
for dep in stage.deps:
dep_key = dep.path_info.parts
overlapping = [n.value for n in outs_trie.prefixes(dep_key)]
if outs_trie.has_subtrie(dep_key):
overlapping.extend(outs_trie.values(prefix=dep_key))
for from_out in stage.outs:
G.add_edges_from((from_out, out) for out in overlapping)
return G
+4 -2
View File
@@ -1,6 +1,8 @@
def imp(self, url, path, out=None, rev=None):
def imp(self, url, path, out=None, fname=None, rev=None, **kwargs):
erepo = {"url": url}
if rev is not None:
erepo["rev"] = rev
return self.imp_url(path, out=out, erepo=erepo, frozen=True)
return self.imp_url(
path, out=out, fname=fname, erepo=erepo, frozen=True, **kwargs
)
+11 -1
View File
@@ -11,7 +11,14 @@ from . import locked
@locked
@scm_context
def imp_url(
self, url, out=None, fname=None, erepo=None, frozen=True, no_exec=False
self,
url,
out=None,
fname=None,
erepo=None,
frozen=True,
no_exec=False,
desc=None,
):
from dvc.dvcfile import Dvcfile
from dvc.stage import Stage, create_stage
@@ -40,6 +47,9 @@ def imp_url(
if stage is None:
return None
if desc:
stage.outs[0].desc = desc
dvcfile = Dvcfile(self, stage.path)
dvcfile.remove()
+9 -35
View File
@@ -1,48 +1,18 @@
import logging
import os
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
from dvc.utils import boxify, relpath
from dvc.utils import relpath
from dvc.utils.fs import remove
logger = logging.getLogger(__name__)
def _welcome_message():
if analytics.is_enabled():
logger.info(
boxify(
"DVC has enabled anonymous aggregate usage analytics.\n"
"Read the analytics documentation (and how to opt-out) here:\n"
"{blue}https://dvc.org/doc/user-guide/analytics{nc}".format(
blue=colorama.Fore.BLUE, nc=colorama.Fore.RESET
),
border_color="red",
)
)
msg = (
"{yellow}What's next?{nc}\n"
"{yellow}------------{nc}\n"
"- Check out the documentation: {blue}https://dvc.org/doc{nc}\n"
"- Get help and share ideas: {blue}https://dvc.org/chat{nc}\n"
"- Star us on GitHub: {blue}https://github.com/iterative/dvc{nc}"
).format(
yellow=colorama.Fore.YELLOW,
blue=colorama.Fore.BLUE,
nc=colorama.Fore.RESET,
)
logger.info(msg)
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
"""
Creates an empty repo on the given directory -- basically a
@@ -99,14 +69,18 @@ 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])
proj.plots.templates.init()
scm.add(
[config.files["repo"], dvcignore, proj.plots.templates.templates_dir]
)
if scm.ignore_file:
scm.add([os.path.join(dvc_dir, scm.ignore_file)])
logger.info("\nYou can now commit the changes to git.\n")
_welcome_message()
return proj
+25 -44
View File
@@ -1,8 +1,9 @@
from itertools import chain
from dvc.exceptions import PathMissingError
from dvc.path_info import PathInfo
@staticmethod
def ls(
url, path=None, rev=None, recursive=None, dvc_only=False,
):
@@ -29,12 +30,14 @@ def ls(
"""
from dvc.external_repo import external_repo
with external_repo(url, rev) as repo:
# use our own RepoTree instance instead of repo.repo_tree since we want to
# fetch directory listings, but don't want to fetch file contents.
with external_repo(url, rev, fetch=False, stream=True) as repo:
path_info = PathInfo(repo.root_dir)
if path:
path_info /= path
ret = _ls(repo, path_info, recursive, dvc_only)
ret = _ls(repo.repo_tree, path_info, recursive, dvc_only)
if path and not ret:
raise PathMissingError(path, repo, dvc_only=dvc_only)
@@ -47,58 +50,36 @@ def ls(
return ret_list
def _ls(repo, path_info, recursive=None, dvc_only=False):
from dvc.repo.tree import RepoTree
def _ls(tree, path_info, recursive=None, dvc_only=False):
def onerror(exc):
raise exc
# use our own RepoTree instance instead of repo.repo_tree since we want to
# 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

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