Compare commits

...
191 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 2d70bc5ec4 dvc: bump to 1.11.0 2020-12-02 17:47:23 +02:00
Peter Rowlands (변기호) 176a23e291 experiments: store experiments in refs/exps instead of heads (#4910)
* experiments: commit to refs/exps/... instead of branch heads

* update exp show

* restore checkpoint_resume kwarg

* refactor new() to use experiments stash

* experiments: use refspecs to populate and collect executors

* experiments: use new stash and merge on exp checkout

* experiments: fix executor chdir

* experiments: update run and show

* skip checkout after reproduce

* checkpoints: use experiments refspecs

* check if workspace was stashed before applying

* exp show: unify checkpoint/standalone experiment collection

* git: move iter_refs to backend

* update for scm changes

* experiments: replace exp checkout in favor of exp apply

* git: handle rev_parse errors in resolve_commit

* update refs containing and baseline check

* fix sigint handling for checkpoints

* update checkpoint exists behavior

* fix checkpoint baseline behavior

* update tests

* experiments: update gc

* update show tests

* fix type checking errors

* update diff

* scm: add diff, describe

* experiments: generate human readable names

* exp run/resume: allow user specified exp name with -n/--name

* update tests

* experiments: add `exp branch` for promoting experiment to full branch

* fix test

* fix missing file

* experiments: explicitly close scm instances before cleaning up tmp dir
2020-12-02 21:20:57 +09:00
Ruslan Kuprieiev c884c94d61 tree: update hint about missing deps (#5005)
Related to #4997
Fixes #4987
2020-12-01 17:57:55 +02:00
Paweł Redzyński f6f18bc201 plots: make linear and scatter interactive (#4975) 2020-12-01 14:55:30 +01:00
Saugat Pachhai 3a3fa7cdde parametrization: convert bool to string as "true"/"false" (#5004)
Previously, Python's `str()` was being used that
resulted in boolean transformed into "True"/"False".

Fixes #4996
2020-12-01 16:05:18 +05:45
Saugat Pachhai 1e1a0999f6 parametrization: show interpolated vars in params diff (#5002)
* parametrization: show interpolated vars in params diff

* Add test for params.diff

* disable logging duration if feature is not enabled
2020-12-01 15:43:37 +05:45
Ruslan Kuprieiev 7f0537038c dvc: preserve desc on add/run/imp (#5003) 2020-12-01 11:52:31 +02:00
Ruslan Kuprieiev 7c45711e34 dvc: preserve comments/meta on add/imp/run (#5001) 2020-12-01 10:39:51 +02:00
Peter Rowlands (변기호) 1e52bc94a3 git: add support for working with refspecs and stashes (#4952)
* git: add support for dulwich Stash

* git: start adding support for custom refs

* git: add Stash and helper context managers for preserving
workspace state

* scm: cleanup refs/stash and add push_refspec

* tests: add tests for git refs and stash

* fix push_refspec

* tests: add tests for git push_refspec

* scm: add git fetch-refspecs

* add test for fetch refspecs

* scm: add test for git refs containing

* remove extraneous log msgs

* fix typing issues

* stash: only try to push if workspace is dirty

* update dulwich to 0.20.14

* scm: move dulwich into scm.backend

* move stash internals to backends

* scm: move git code into dvc/scm/git/... module

* git: use ABC for BaseGitBackend

* fix rebase issue

* git: fix refspec tests on windows

- git file:// URLs need posix style paths

* git: move get_refs_containing to backend

* scm: add callback for handling diverged refs during fetch
2020-12-01 16:56:50 +09:00
Artem Dudarev 38cbd998e1 dvc: more explict error during init when .dvc is ignored by git (#4978)
* dvc: more explict error during init when .dvc is ignored by git

Fixes #3738

* dvc: address PR feedback

* styling

* styling
2020-11-30 21:00:17 +02:00
Ruslan Kuprieiev 09a88b8ea6 stage: move is_cached logic to Stage.can_be_skipped (#4994)
Having that logic in `create_stage` is unnatural and also has some
side-effects for unsuspecting users. For example, it will check the
filesystem for existing dvcfile and will try to load a corresponding old
stage. I've adjusted it with minimal changes to better reflect the logic
behind it.

Pre-requisite for #4907
2020-11-30 15:11:35 +02:00
Saugat Pachhai bc1d10532a Move Repo.collect and collect_granular to Repo.stage (#4993) 2020-11-30 14:28:16 +05:45
Jorge Orpinel 56322d0a53 cmd: matching help output to docs (#4989)
* cmd: matching help output to docs
per https://github.com/iterative/dvc.org/pull/1971#pullrequestreview-538749492
and https://github.com/iterative/dvc.org/pull/1978#pullrequestreview-540445147

* fetch: simplify help text
per https://github.com/iterative/dvc.org/pull/1978#pullrequestreview-540447055
2020-11-30 09:09:11 +02:00
Saugat Pachhai f4f0554884 repro: support glob/foreach-group to run at once through CLI (#4976)
* repro: support regex/foreach-group to run at once

Fixes #4912
Fixes #4886
Fixes #4958

* Use `tree.isdir` rather than `os.path.isdir`

* Use glob rather than regex

* Update dvc/command/repro.py

* s/regex/glob

* disable glob on `collect_granular`

There's no need for a glob here

* add tests for `collect` and `collect_granular`
2020-11-27 19:13:38 +05:45
Saugat Pachhai 011bd18e9f parametrization: support loading vars partially (#4982) 2020-11-27 19:05:02 +05:45
Saugat Pachhai 6d745eb4f0 s3: use mocked s3 instead of the real one (#4968)
* s3: use mocked s3 instead of the real one

Not removing the fixtures just yet

* Update moto

Tests was failing, because of a change in `responses` package, which
`moto` was mocking. This was fixed on
https://github.com/spulec/moto/pull/3466 which is only available on
`dev122` release.

* Use mocked s3 in checkout tests
2020-11-26 11:30:43 +05:45
Saugat Pachhai 788d840e7d tests/ci: update docker image for Azurite (#4967)
`Azure` tests are flaky for some reason. When I investigated one
of the job [1], something seemed wrong with the Azurite or
the client lib.

On `AzureTree.blob_service`, we do the following to check if the
container exists:
```python
try:
    container_client.get_container_properties()
except ResourceNotFoundError:
    container_client.create_container()
```

When looking at the test log, I found out that the
`get_container_properties` was failing with
`ErrorCode:ContainerNotFound` whereas `create_container`
successively was failing with `ErrorCode:ContainerAlreadyExists`.

`create_container` is correct, but `get_container_properties` is
incorrect, as many of the Azure tests have already run successfully
before.

So, either the Azurite has/had bug or the client lib has a bug.
Anyway, we'll find out after updating the Azurite.

[1]: https://github.com/iterative/dvc/runs/1448864706#step:7:4029
2020-11-25 21:21:00 +02:00
Saugat Pachhai 7586c32d95 Remove libs used in pre-commit to avoid duplication (#4966) 2020-11-25 18:30:28 +05:45
Saugat Pachhai dcba0e8da4 pre-commit: update hooks (#4964) 2020-11-25 17:57:32 +05:45
Saugat Pachhai 1294450a46 parse: parse target correctly for generated stages (#4961) 2020-11-25 16:13:17 +05:45
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
245 changed files with 12219 additions and 4412 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
+10 -10
View File
@@ -9,9 +9,9 @@ jobs:
linux:
runs-on: ubuntu-16.04
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v2.3.2
- name: Set up Python 3.7
uses: actions/setup-python@v1
uses: actions/setup-python@v2.1.4
with:
python-version: 3.7
- name: Set up Ruby 2.4
@@ -43,9 +43,9 @@ jobs:
mac:
runs-on: macos-10.15
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v2.3.2
- name: Set up Python 3.7
uses: actions/setup-python@v1
uses: actions/setup-python@v2.1.4
with:
python-version: 3.7
- name: Set up Ruby 2.4
@@ -67,9 +67,9 @@ jobs:
windows:
runs-on: windows-2019
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v2.3.2
- name: Set up Python 3.7
uses: actions/setup-python@v1
uses: actions/setup-python@v2.1.4
with:
python-version: 3.7
- name: Build
@@ -77,9 +77,9 @@ jobs:
shell: cmd
- name: Publish
if: github.event_name == 'release'
uses: actions/[email protected]
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
@@ -88,9 +88,9 @@ jobs:
python:
runs-on: ubuntu-16.04
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v2.3.2
- name: Set up Python 3.7
uses: actions/setup-python@v1
uses: actions/setup-python@v2.1.4
with:
python-version: 3.7
- name: Install
@@ -106,7 +106,7 @@ jobs:
snap:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v2.3.2
- name: Build the package
uses: snapcore/action-build@v1
id: snapcraft
+42 -17
View File
@@ -1,20 +1,29 @@
name: Tests build
name: Tests
on: [push, pull_request]
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/checkout@v1
- uses: actions/checkout@v2.3.2
- name: Set up Python 3.8
uses: actions/setup-python@v1
uses: actions/setup-python@v2.1.4
with:
python-version: 3.8
- name: Install requirements
@@ -29,33 +38,49 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-18.04, windows-2019, macos-10.15]
pyv: [3.6, 3.7, 3.8, "3.9-dev"]
pyv: ["3.6", "3.7", "3.8", "3.9"]
include:
- os: "macos-11.0"
pyv: "3.9"
exclude:
- os: windows-2019
pyv: "3.9-dev"
- os: macos-10.15
pyv: "3.9"
steps:
- uses: actions/[email protected]
- name: Set up Python
uses: actions/[email protected].2
uses: actions/[email protected].4
with:
python-version: ${{ matrix.pyv }}
- name: Install libxml2 and libxslt
if: matrix.pyv == '3.9-dev' && matrix.os == 'ubuntu-18.04'
# need to install for building lxml, a dependency of webdavclient3
run: |
sudo apt install libxml2 libxslt-dev
- name: install
run: |
pip install ".[all,tests]"
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 -ra --cov-report=xml
run: python -m tests --all --cov-report=xml --cov-report=term --tap-combined
- name: upload coverage report
if: github.event.name == 'push'
uses: codecov/[email protected]
with:
file: ./coverage.xml
fail_ci_if_error: true
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
View File
@@ -5,6 +5,7 @@ neatlynx/__pycache__
*.pyc
.env/
.env2.7/
.python-version
.dvc.conf.lock
.DS_Store
+3 -3
View File
@@ -8,7 +8,7 @@ repos:
- id: isort
language_version: python3
repo: https://github.com/timothycrosley/isort
rev: 5.4.2
rev: 5.6.4
- hooks:
- id: flake8
language_version: python3
@@ -18,7 +18,7 @@ repos:
- flake8-debugger
- flake8-string-format
repo: https://gitlab.com/pycqa/flake8
rev: 3.8.3
rev: 3.8.4
- repo: local
hooks:
- id: pylint
@@ -33,7 +33,7 @@ repos:
id: beautysh
language_version: python3
repo: https://github.com/lovesegfault/beautysh
rev: master
rev: 6.0.1
- hooks:
- args:
- git-hook
+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:v5.4.2
command:
- isort
arguments: []
include:
- "**/*.py"
interpreters:
- python
+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
+5 -5
View File
@@ -20,7 +20,7 @@ def get_url(path, repo=None, rev=None, remote=None):
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 OutputNotFoundError if the file is not a dvc-tracked file.
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.
@@ -34,8 +34,7 @@ def get_url(path, repo=None, rev=None, remote=None):
raise OutputNotFoundError(path, repo)
cloud = metadata.repo.cloud
with _repo.state:
hash_info = _repo.repo_tree.get_hash(path_info)
hash_info = _repo.repo_tree.get_hash(path_info)
return cloud.get_url_for(remote, checksum=hash_info.value)
@@ -122,12 +121,13 @@ def make_checkpoint():
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:
if os.getenv(DVC_CHECKPOINT) is None:
return
root_dir = Repo.find_root()
root_dir = os.getenv(DVC_ROOT, Repo.find_root())
signal_file = os.path.join(
root_dir, Repo.DVC_DIR, "tmp", CHECKPOINT_SIGNAL_FILE
)
+39 -42
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,21 +36,25 @@ 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:
self.local = None
return
settings = None
else:
from ..config import LOCAL_COMMON
@@ -73,14 +63,21 @@ class Cache:
if opt in config:
settings[str(opt)] = config.get(opt)
tree = get_cloud_tree(repo, **settings)
self.local = LocalCache(tree)
self._cache[Schemes.LOCAL] = _get_cache(repo, settings)
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")
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:
+378 -148
View File
@@ -1,39 +1,30 @@
import itertools
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from copy import copy
from operator import itemgetter
from typing import Optional
from funcy import decorator
from shortuuid import uuid
import dvc.prompt as prompt
from dvc.dir_info import DirInfo
from dvc.exceptions import (
CacheLinkError,
CheckoutError,
ConfirmRemoveError,
DvcException,
MergeError,
)
from dvc.hash_info import HashInfo
from dvc.path_info import WindowsPathInfo
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_):
@@ -42,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
@@ -68,7 +66,7 @@ class CloudCache:
try:
dir_info = self.load_dir_cache(hash_info)
except DirCacheError:
dir_info = []
dir_info = DirInfo()
self._dir_info[hash_info.value] = dir_info
return dir_info
@@ -87,18 +85,9 @@ class CloudCache:
"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.
@@ -184,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):
@@ -218,7 +207,7 @@ 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_info.value)
self.tree.state.save(path_info, hash_info)
else:
if self.changed_cache(hash_info):
with tree.open(path_info, mode="rb") as fobj:
@@ -232,7 +221,7 @@ class CloudCache:
if callback:
callback(1)
self.tree.state.save(cache_info, hash_info.value)
self.tree.state.save(cache_info, hash_info)
def _cache_is_copy(self, path_info):
"""Checks whether cache uses copies."""
@@ -262,14 +251,9 @@ class CloudCache:
from dvc.path_info import PathInfo
from dvc.utils import tmp_fname
# Sorting the list by path to ensure reproducibility
if isinstance(dir_info, dict):
dir_info = self._from_dict(dir_info)
dir_info = sorted(dir_info, key=itemgetter(self.tree.PARAM_RELPATH))
tmp = tempfile.NamedTemporaryFile(delete=False).name
with open(tmp, "w+") as fobj:
json.dump(dir_info, fobj, sort_keys=True)
json.dump(dir_info.to_list(), fobj, sort_keys=True)
from_info = PathInfo(tmp)
to_info = self.tree.path_info / tmp_fname("")
@@ -277,10 +261,12 @@ class CloudCache:
hash_info = self.tree.get_file_hash(to_info)
hash_info.value += self.tree.CHECKSUM_DIR_SUFFIX
hash_info.dir_info = self._to_dict(dir_info)
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
@@ -295,20 +281,19 @@ class CloudCache:
self.tree.makedirs(new_info.parent)
self.tree.move(tmp_info, new_info, mode=self.CACHE_MODE)
self.tree.state.save(new_info, hi.value)
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 = self._to_dict(
tree.cache.get_dir_cache(hash_info)
)
hash_info.dir_info = tree.cache.get_dir_cache(hash_info)
hi = self.save_dir_info(hash_info.dir_info, hash_info)
for relpath, entry_hash in Tqdm(
hi.dir_info.items(), desc="Saving " + path_info.name, unit="file"
for entry_info, entry_hash in Tqdm(
hi.dir_info.items(path_info),
desc="Saving " + path_info.name,
unit="file",
):
entry_info = path_info / relpath
self._save_file(
entry_info, tree, entry_hash, save_link=False, **kwargs
)
@@ -316,11 +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, hi.value)
self.tree.state.save(path_info, hi)
cache_info = self.tree.hash_to_path_info(hi.value)
self.tree.state.save(cache_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(
@@ -392,13 +378,9 @@ class CloudCache:
if self.changed_cache_file(hash_info):
return True
for entry in self.get_dir_cache(hash_info):
entry_hash = HashInfo(
self.tree.PARAM_CHECKSUM, 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
@@ -450,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_info.value)
self.tree.state.save(path_info, hash_info)
if progress_callback:
progress_callback(str(path_info))
@@ -476,21 +458,19 @@ class CloudCache:
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 = HashInfo(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))
@@ -500,7 +480,7 @@ class CloudCache:
)
self.tree.state.save_link(path_info)
self.tree.state.save(path_info, hash_info.value)
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
@@ -508,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,
@@ -525,6 +504,7 @@ class CloudCache:
progress_callback=None,
relink=False,
filter_info=None,
quiet=False,
):
if path_info.scheme not in ["local", self.tree.scheme]:
raise NotImplementedError
@@ -532,10 +512,11 @@ class CloudCache:
failed = None
skip = False
if not hash_info:
logger.warning(
"No file hash info found for '%s'. " "It won't be created.",
path_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
@@ -546,11 +527,12 @@ class CloudCache:
elif self.changed_cache(
hash_info, path_info=path_info, filter_info=filter_info
):
logger.warning(
"Cache '%s' not found. File '%s' won't be created.",
hash_info,
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
@@ -607,76 +589,21 @@ class CloudCache:
return 1
if not filter_info:
return len(self.get_dir_cache(hash_info))
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_info)
filter_info.isin_or_eq(path_info / relpath)
for relpath, _ in self.get_dir_cache(hash_info).items()
)
def _to_dict(self, dir_info):
info = {}
for entry in dir_info:
relpath = None
hash_info = None
for key, value in entry.items():
if key == self.tree.PARAM_RELPATH:
relpath = value
else:
hash_info = HashInfo(key, value)
info[relpath] = hash_info
return info
def _from_dict(self, dir_dict):
return [
{
self.tree.PARAM_RELPATH: relpath,
hash_info.name: hash_info.value,
}
for relpath, hash_info in dir_dict.items()
]
@staticmethod
def _diff(ancestor, other, allow_removed=False):
from dictdiffer import diff
allowed = ["add"]
if allow_removed:
allowed.append("remove")
result = list(diff(ancestor, other))
for typ, _, _ in result:
if typ not in allowed:
raise MergeError(
"unable to auto-merge directories with diff that contains "
f"'{typ}'ed files"
)
return result
def _merge_dirs(self, ancestor_info, our_info, their_info):
from dictdiffer import patch
ancestor = self._to_dict(ancestor_info)
our = self._to_dict(our_info)
their = self._to_dict(their_info)
our_diff = self._diff(ancestor, our)
if not our_diff:
return self._from_dict(their)
their_diff = self._diff(ancestor, their)
if not their_diff:
return self._from_dict(our)
# make sure there are no conflicting files
self._diff(our, their, allow_removed=True)
merged = patch(our_diff + their_diff, ancestor, in_place=True)
# Sorting the list by path to ensure reproducibility
return sorted(
self._from_dict(merged), key=itemgetter(self.tree.PARAM_RELPATH)
)
def _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
@@ -685,23 +612,326 @@ class CloudCache:
if ancestor_info:
ancestor = self.get_dir_cache(ancestor_info)
else:
ancestor = []
ancestor = DirInfo()
our = self.get_dir_cache(our_info)
their = self.get_dir_cache(their_info)
merged = self._merge_dirs(ancestor, our, their)
return self.save_dir_info(merged)
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
return self.save_dir_info(hash_info.dir_info, 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._to_dict(self.get_dir_cache(hash_info))
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))
+22 -403
View File
@@ -1,53 +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)
@@ -61,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)
@@ -102,371 +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,
log_missing=True,
):
# Return flattened dict containing all status info
dir_status, file_status, _ = self._status(
named_cache,
remote,
jobs=jobs,
show_checksums=show_checksums,
download=download,
log_missing=log_missing,
)
return dict(dir_status, **file_status)
def _status(
self,
named_cache,
remote,
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 {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,
log_missing,
)
def _make_status(
self,
named_cache,
show_checksums,
local_exists,
remote_exists,
log_missing,
):
def make_names(hash_, names):
return {"name": hash_ if show_checksums else " ".join(names)}
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)
if log_missing:
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 = []
missing = []
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)
elif info["status"] == STATUS_MISSING:
missing.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), missing
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, missing_files = 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):
# 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:
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):
ret = self._process(
named_cache,
remote,
jobs=jobs,
show_checksums=show_checksums,
download=True,
)
if not remote.tree.verify:
for checksum in named_cache.scheme_keys("local"):
cache_file = self.tree.hash_to_path_info(checksum)
if self.tree.exists(cache_file):
# We can safely save here, as existing corrupted files will
# be removed upon status, while files corrupted during
# download will not be moved from tmp_file
# (see `BaseTree.download()`)
self.tree.state.save(cache_file, checksum)
return ret
@staticmethod
def _log_missing_caches(hash_info_dict):
missing_caches = [
(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)
+4
View File
@@ -146,6 +146,10 @@ def get_parent_parser():
)
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."
+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
+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)
+72 -40
View File
@@ -2,7 +2,7 @@ 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__)
@@ -30,58 +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.stage.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
def _relabel(node) -> str:
return node.addressing if isinstance(node, Stage) else str(node)
if target:
H = get_pipeline(get_pipelines(G), target)
if not full:
descendants = nx.descendants(G, target)
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):
@@ -108,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="?",
+1 -4
View File
@@ -237,10 +237,7 @@ def add_parser(subparsers, _parent_parser):
push_parser.set_defaults(func=CmdDataPush)
# Fetch
FETCH_HELP = (
"Get tracked files or directories from remote storage"
" into the cache."
)
FETCH_HELP = "Download cached files or directories from remote storage."
fetch_parser = subparsers.add_parser(
"fetch",
+316 -193
View File
@@ -3,16 +3,19 @@ import io
import logging
import os
from collections import OrderedDict
from datetime import date
from collections.abc import Mapping
from datetime import date, datetime
from itertools import groupby
from typing import Iterable, Optional
from dvc.command import completion
import dvc.prompt as prompt
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.command.metrics import DEFAULT_PRECISION, _show_metrics
from dvc.command.status import CmdDataStatus
from dvc.dvcfile import PIPELINE_FILE
from dvc.command.metrics import DEFAULT_PRECISION
from dvc.command.repro import CmdRepro
from dvc.command.repro import add_arguments as add_repro_arguments
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.repo.experiments import Experiments
from dvc.scm.git import Git
from dvc.utils.flatten import flatten
logger = logging.getLogger(__name__)
@@ -48,13 +51,13 @@ def _filter_names(
update_func({match: None for match in matches})
if include:
ret = OrderedDict()
ret: OrderedDict = OrderedDict()
_filter(include, ret.update)
else:
ret = OrderedDict({name: None for name in names})
if exclude:
_filter(exclude, ret.difference_update)
_filter(exclude, ret.difference_update) # type: ignore[attr-defined]
return [".".join(name) for name in ret]
@@ -113,30 +116,48 @@ def _collect_rows(
reverse = sort_order == "desc"
experiments = _sort_exp(experiments, sort_by, sort_type, reverse)
last_tip = None
new_checkpoint = True
for i, (rev, exp) in enumerate(experiments.items()):
row = []
style = None
queued = "*" if exp.get("queued", False) else ""
tip = exp.get("checkpoint_tip")
parent = ""
if rev == "baseline":
name = exp.get("name", base_rev)
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 and tip == last_tip:
tree = "│ ╟"
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:
if tip:
tree = "├─╥"
else:
tree = "├──"
tree = "├──"
else:
tree = "└──"
row.append(f"{tree} {queued}{rev[:7]}")
last_tip = tip
new_checkpoint = True
name = exp.get("name", rev[:7])
row.append(f"{tree} {queued}{name}{parent}")
if not no_timestamp:
row.append(_format_time(exp.get("timestamp")))
@@ -150,13 +171,12 @@ def _collect_rows(
def _sort_exp(experiments, sort_by, typ, reverse):
if "baseline" in experiments:
ret = OrderedDict({"baseline": experiments.pop("baseline")})
else:
ret = OrderedDict()
def _sort(item):
_, exp = 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)
@@ -167,6 +187,10 @@ def _sort_exp(experiments, sort_by, typ, reverse):
return (val is None, val)
return (True, None)
ret = OrderedDict()
if "baseline" in experiments:
ret["baseline"] = experiments.pop("baseline")
ret.update(sorted(experiments.items(), key=_sort, reverse=reverse))
return ret
@@ -181,12 +205,19 @@ def _format_time(timestamp):
return timestamp.strftime(fmt)
def _extend_row(row, names, items, precision):
def _round(val):
if isinstance(val, float):
return round(val, precision)
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)
return val
def _extend_row(row, names, items, precision):
from rich.text import Text
if not items:
row.extend(["-"] * len(names))
@@ -200,7 +231,13 @@ def _extend_row(row, names, items, precision):
for name in names:
if name in item:
value = item[name]
text = str(_round(value)) if value is not None else "-"
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("-")
@@ -220,8 +257,6 @@ def _parse_list(param_list):
def _show_experiments(all_experiments, console, **kwargs):
from rich.table import Table
from dvc.scm.git import Git
include_metrics = _parse_list(kwargs.pop("include_metrics", []))
exclude_metrics = _parse_list(kwargs.pop("exclude_metrics", []))
include_params = _parse_list(kwargs.pop("include_params", []))
@@ -245,9 +280,6 @@ def _show_experiments(all_experiments, console, **kwargs):
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, **kwargs,
):
@@ -256,6 +288,12 @@ def _show_experiments(all_experiments, console, **kwargs):
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
@@ -273,6 +311,12 @@ class CmdExperimentsShow(CmdBase):
sha_only=self.args.sha,
)
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:
@@ -282,6 +326,11 @@ class CmdExperimentsShow(CmdBase):
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,
@@ -292,6 +341,7 @@ class CmdExperimentsShow(CmdBase):
no_timestamp=self.args.no_timestamp,
sort_by=self.args.sort_by,
sort_order=self.args.sort_order,
precision=precision,
)
if not self.args.no_pager:
@@ -303,30 +353,26 @@ class CmdExperimentsShow(CmdBase):
return 0
class CmdExperimentsCheckout(CmdBase):
class CmdExperimentsApply(CmdBase):
def run(self):
if not self.repo.experiments:
return 0
self.repo.experiments.checkout(self.args.experiment)
self.repo.experiments.apply(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()))
@@ -334,9 +380,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"]
@@ -367,6 +417,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(
@@ -375,7 +430,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)
@@ -388,7 +443,7 @@ class CmdExperimentsDiff(CmdBase):
return 0
class CmdExperimentsRun(CmdBase):
class CmdExperimentsRun(CmdRepro):
def run(self):
if not self.repo.experiments:
return 0
@@ -405,38 +460,16 @@ class CmdExperimentsRun(CmdBase):
ret = 0
for target in self.args.targets:
try:
stages = self.repo.reproduce(
self.repo.experiments.run(
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=True,
name=self.args.name,
queue=self.args.queue,
run_all=self.args.run_all,
jobs=self.args.jobs,
params=self.args.params,
checkpoint=(
self.args.checkpoint
or self.args.checkpoint_continue is not None
),
checkpoint_continue=self.args.checkpoint_continue,
checkpoint_resume=self.args.checkpoint_resume,
**self._repro_kwargs,
)
if len(stages) == 0:
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
if self.args.metrics:
metrics = self.repo.metrics.show()
logger.info(_show_metrics(metrics))
except DvcException:
logger.exception("")
ret = 1
@@ -446,6 +479,71 @@ class CmdExperimentsRun(CmdBase):
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
class CmdExperimentsBranch(CmdBase):
def run(self):
if not self.repo.experiments:
return 0
self.repo.experiments.branch(self.args.experiment, self.args.branch)
return 0
def add_parser(subparsers, parent_parser):
EXPERIMENTS_HELP = "Commands to display and compare experiments."
@@ -550,22 +648,39 @@ def add_parser(subparsers, parent_parser):
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."
experiments_checkout_parser = experiments_subparsers.add_parser(
"checkout",
EXPERIMENTS_APPLY_HELP = (
"Apply the changes from an experiment to your workspace."
)
experiments_apply_parser = experiments_subparsers.add_parser(
"apply",
parents=[parent_parser],
description=append_doc_link(
EXPERIMENTS_CHECKOUT_HELP, "experiments/checkout"
EXPERIMENTS_APPLY_HELP, "experiments/apply"
),
help=EXPERIMENTS_CHECKOUT_HELP,
help=EXPERIMENTS_APPLY_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_checkout_parser.add_argument(
"experiment", help="Checkout this experiment.",
experiments_apply_parser.add_argument(
"experiment", help="Experiment to be applied.",
)
experiments_checkout_parser.set_defaults(func=CmdExperimentsCheckout)
experiments_apply_parser.set_defaults(func=CmdExperimentsApply)
EXPERIMENTS_DIFF_HELP = (
"Show changes between experiments in the DVC repository."
@@ -636,145 +751,153 @@ def add_parser(subparsers, parent_parser):
help=EXPERIMENTS_RUN_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
_add_run_common(experiments_run_parser)
experiments_run_parser.add_argument(
"targets",
nargs="*",
help=f"Stages to reproduce. '{PIPELINE_FILE}' by default.",
).complete = completion.DVC_FILE
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="Reproduce even if dependencies were not changed.",
help="Force garbage collection - automatically agree to all prompts.",
)
experiments_run_parser.add_argument(
"-s",
"--single-item",
action="store_true",
default=False,
help="Reproduce only single data item without recursive dependencies "
"check.",
experiments_gc_parser.set_defaults(func=CmdExperimentsGC)
EXPERIMENTS_BRANCH_HELP = "Promote an experiment to a Git branch."
experiments_branch_parser = experiments_subparsers.add_parser(
"branch",
parents=[parent_parser],
description=append_doc_link(
EXPERIMENTS_BRANCH_HELP, "experiments/branch"
),
help=EXPERIMENTS_BRANCH_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_run_parser.add_argument(
"-c",
"--cwd",
default=os.path.curdir,
help="Directory within your repo to reproduce from. Note: deprecated "
"by `dvc --cd <path>`.",
metavar="<path>",
experiments_branch_parser.add_argument(
"experiment", help="Experiment to be promoted.",
)
experiments_run_parser.add_argument(
"-m",
"--metrics",
action="store_true",
default=False,
help="Show metrics after reproduction.",
experiments_branch_parser.add_argument(
"branch", help="Git branch name to use.",
)
experiments_run_parser.add_argument(
"--dry",
action="store_true",
default=False,
help="Only print the commands that would be executed without "
"actually executing.",
)
experiments_run_parser.add_argument(
"-i",
"--interactive",
action="store_true",
default=False,
help="Ask for confirmation before reproducing each stage.",
)
experiments_run_parser.add_argument(
"-p",
"--pipeline",
action="store_true",
default=False,
help="Reproduce the whole pipeline that the specified stage file "
"belongs to.",
)
experiments_run_parser.add_argument(
"-P",
"--all-pipelines",
action="store_true",
default=False,
help="Reproduce all pipelines in the repo.",
)
experiments_run_parser.add_argument(
"-R",
"--recursive",
action="store_true",
default=False,
help="Reproduce all stages in the specified directory.",
)
experiments_run_parser.add_argument(
"--no-run-cache",
action="store_true",
default=False,
experiments_branch_parser.set_defaults(func=CmdExperimentsBranch)
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(
"-n",
"--name",
default=None,
help=(
"Execute stage commands even if they have already been run with "
"the same command/dependencies/outputs/etc before."
"Human-readable experiment name. If not specified, a name will "
"be auto-generated."
),
)
experiments_run_parser.add_argument(
"--force-downstream",
action="store_true",
default=False,
help="Reproduce all descendants of a changed stage even if their "
"direct dependencies didn't change.",
)
experiments_run_parser.add_argument(
"--no-commit",
action="store_true",
default=False,
help="Don't put files/directories into cache.",
)
experiments_run_parser.add_argument(
"--downstream",
action="store_true",
default=False,
help="Start from the specified stages when reproducing pipelines.",
)
experiments_run_parser.add_argument(
parser.add_argument(
"--params",
action="append",
default=[],
help="Use the specified param values when reproducing pipelines.",
metavar="[<filename>:]<params_list>",
)
experiments_run_parser.add_argument(
parser.add_argument(
"--queue",
action="store_true",
default=False,
help="Stage this experiment in the run queue for future execution.",
)
experiments_run_parser.add_argument(
parser.add_argument(
"--run-all",
action="store_true",
default=False,
help="Execute all experiments in the run queue.",
)
experiments_run_parser.add_argument(
parser.add_argument(
"-j",
"--jobs",
type=int,
help="Run the specified number of experiments at a time in parallel.",
metavar="<number>",
)
experiments_run_parser.add_argument(
"--checkpoint",
action="store_true",
default=False,
help="Reproduce pipelines as a checkpoint experiment.",
)
experiments_run_parser.add_argument(
"--continue",
nargs=1,
default=None,
dest="checkpoint_continue",
help=(
"Continue from the specified checkpoint experiment"
"(implies --checkpoint)."
),
)
experiments_run_parser.set_defaults(func=CmdExperimentsRun)
+17
View File
@@ -17,6 +17,8 @@ class CmdImport(CmdBase):
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(
@@ -65,4 +67,19 @@ def add_parser(subparsers, parent_parser):
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)
+10
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(
@@ -67,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)
+28
View File
@@ -1,11 +1,38 @@
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.exceptions import InitError
@@ -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
+7 -8
View File
@@ -70,7 +70,7 @@ class CmdMetricsShow(CmdMetricsBase):
if table:
logger.info(table)
except DvcException:
logger.exception("failed to show metrics")
logger.exception("")
return 1
return 0
@@ -171,9 +171,8 @@ def add_parser(subparsers, parent_parser):
"targets",
nargs="*",
help=(
"Limit command scope to these metric files (supports any file, "
"even when not found as `metrics` in `dvc.yaml`). 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(
@@ -209,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)
@@ -237,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
@@ -249,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(
+8 -7
View File
@@ -31,8 +31,6 @@ DIV_HTML = """<div id = "{id}"></div>
class CmdPlots(CmdBase):
UNINITIALIZED = True
def _func(self, *args, **kwargs):
raise NotImplementedError
@@ -67,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("")
@@ -82,11 +79,15 @@ 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,
@@ -124,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],
@@ -176,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],
+2 -1
View File
@@ -21,7 +21,8 @@ class CmdRemove(CmdBase):
def add_parser(subparsers, parent_parser):
REMOVE_HELP = (
"Remove stage entry, remove .gitignore entry and unprotect outputs"
"Remove stages or .dvc files, unprotect their outputs, and"
" erase .gitignore entries."
)
remove_parser = subparsers.add_parser(
"remove",
+45 -57
View File
@@ -26,29 +26,15 @@ 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,
queue=self.args.queue,
run_all=self.args.run_all,
jobs=self.args.jobs,
params=self.args.params,
pull=self.args.pull,
)
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()
@@ -62,18 +48,26 @@ 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,
"glob": self.args.glob,
}
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="*",
@@ -173,32 +167,6 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Start from the specified stages when reproducing pipelines.",
)
repro_parser.add_argument(
"-e",
"--experiment",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
repro_parser.add_argument(
"--params",
action="append",
default=[],
help=argparse.SUPPRESS,
metavar="[<filename>:]<params_list>",
)
repro_parser.add_argument(
"--queue", action="store_true", default=False, help=argparse.SUPPRESS
)
repro_parser.add_argument(
"--run-all",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
repro_parser.add_argument(
"-j", "--jobs", type=int, help=argparse.SUPPRESS, metavar="<number>"
)
repro_parser.add_argument(
"--pull",
action="store_true",
@@ -208,4 +176,24 @@ def add_parser(subparsers, parent_parser):
"from the run-cache."
),
)
repro_parser.add_argument(
"--glob",
action="store_true",
default=False,
help="Allows targets containing shell-style wildcards.",
)
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)
+23 -3
View File
@@ -21,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,
]
@@ -45,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("")
@@ -134,7 +137,7 @@ def add_parser(subparsers, parent_parser):
"--metrics",
action="append",
default=[],
help="Declare output metric file.",
help="Declare output metrics file.",
metavar="<path>",
)
run_parser.add_argument(
@@ -142,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(
@@ -212,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",
@@ -230,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."
)
+6
View File
@@ -11,6 +11,7 @@ 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!"
@@ -68,6 +69,11 @@ class CmdDataStatus(CmdDataBase):
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)
+1
View File
@@ -24,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)
+26 -1
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,
}
@@ -148,6 +149,7 @@ SCHEMA = {
"s3": str,
"gs": str,
"hdfs": str,
"webhdfs": str,
"ssh": str,
"azure": str,
# This is for default local cache
@@ -194,6 +196,13 @@ SCHEMA = {
**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,
@@ -224,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)
@@ -375,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)
@@ -401,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)
+12 -6
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,8 +84,11 @@ class DataCloud:
"""
remote = self.get_remote(remote, "pull")
return 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,
)
def status(
@@ -107,10 +113,10 @@ class DataCloud:
neither in cache, neither in cloud.
"""
remote = self.get_remote(remote, "status")
return self.repo.cache.local.status(
return remote.status(
self.repo.cache.local,
cache,
jobs=jobs,
remote=remote,
show_checksums=show_checksums,
log_missing=log_missing,
)
+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
+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
+16 -4
View File
@@ -36,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,
@@ -45,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(
@@ -193,7 +201,13 @@ class PipelineFile(FileMixin):
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):
self._check_if_parametrized(stage)
stage_data = serialize.to_pipeline_file(stage)
with modify_yaml(self.path, tree=self.repo.tree) as data:
@@ -209,8 +223,6 @@ class PipelineFile(FileMixin):
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)
@@ -227,7 +239,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:
+2
View File
@@ -1,2 +1,4 @@
DVC_CHECKPOINT = "DVC_CHECKPOINT"
DVC_DAEMON = "DVC_DAEMON"
DVC_PAGER = "DVC_PAGER"
DVC_ROOT = "DVC_ROOT"
+36 -7
View File
@@ -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,7 +164,7 @@ 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)
)
)
@@ -174,11 +174,20 @@ class NoMetricsError(DvcException):
pass
class NoPlotsError(DvcException):
def __init__(self):
class NoMetricsParsedError(NoMetricsError):
def __init__(self, command):
super().__init__(
"no plots in this repository. Use `--plots/--plots-no-cache` "
"options for `dvc run` to mark stage outputs as plots."
f"Could not parse {command} files. Use `-v` option to see more "
"details."
)
class NoMetricsFoundError(NoMetricsError):
def __init__(self, command, run_options):
super().__init__(
f"No {command} files in this repository. "
f"Use `{run_options}` options for "
f"`dvc run` to mark stage outputs as {command}."
)
@@ -218,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__(
@@ -338,3 +352,18 @@ class NoOutputOrStageError(DvcException):
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
+40 -93
View File
@@ -4,7 +4,7 @@ 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, reraise, retry, wrap_with
@@ -22,7 +22,6 @@ from dvc.path_info import PathInfo
from dvc.repo import Repo
from dvc.scm.base import CloneError
from dvc.scm.git import Git
from dvc.state import StateNoop
from dvc.tree.local import LocalTree
from dvc.tree.repo import RepoTree
from dvc.utils import relpath
@@ -51,12 +50,10 @@ def external_repo(url, rev=None, for_write=False, **kwargs):
scm=None if for_write else Git(root_dir),
rev=None if for_write else rev,
for_write=for_write,
uninitialized=True,
**kwargs,
)
try:
repo = ExternalRepo(**conf)
except NotDvcRepoError:
repo = ExternalGitRepo(**conf)
repo = ExternalRepo(**conf)
try:
yield repo
@@ -76,8 +73,8 @@ def external_repo(url, rev=None, for_write=False, **kwargs):
_remove(path)
CLONES = {}
CACHE_DIRS = {}
CLONES: Dict[str, str] = {}
CACHE_DIRS: Dict[str, str] = {}
def clean_repos():
@@ -90,9 +87,34 @@ def clean_repos():
_remove(path)
class BaseExternalRepo:
class ExternalRepo(Repo):
# pylint: disable=no-member
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
@@ -108,13 +130,15 @@ class BaseExternalRepo:
return self.scm.get_rev()
return self.tree.rev
def _fetch_to_cache(self, path_info, repo, callback):
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)
hash_info = tree.get_hash(
path_info, download_callback=callback, **kwargs
)
cache.save(
path_info,
tree,
@@ -148,7 +172,9 @@ class BaseExternalRepo:
self._check_repo(path, metadata.repo)
repo = metadata.repo
hash_info = self._fetch_to_cache(path, repo, download_update)
hash_info = self._fetch_to_cache(
path, repo, download_update, **kwargs
)
hash_infos.append(hash_info)
return sum(download_results), failed, hash_infos
@@ -230,7 +256,8 @@ class BaseExternalRepo:
return repo
def _setup_cache(self, repo):
repo.cache.local.cache_dir = self.cache_dir
repo.config["cache"]["dir"] = self.cache_dir
repo.cache = Cache(repo)
if self.cache_types:
repo.cache.local.cache_types = self.cache_types
@@ -262,86 +289,6 @@ class BaseExternalRepo:
return cache_dir
class ExternalRepo(BaseExternalRepo, Repo):
def __init__(
self,
root_dir,
url,
scm=None,
rev=None,
for_write=False,
cache_dir=None,
cache_types=None,
**kwargs,
):
super().__init__(root_dir, scm=scm, rev=rev)
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
class ExternalGitRepo(BaseExternalRepo):
def __init__(
self,
root_dir,
url,
scm=None,
rev=None,
for_write=False,
cache_dir=None,
cache_types=None,
**kwargs,
):
self.root_dir = os.path.realpath(root_dir)
self.scm = scm
self.url = url
self.for_write = for_write
self.cache_dir = cache_dir or self._get_cache_dir()
self.cache_types = cache_types
self.rev = rev
self.tree_confs = kwargs
self.config = {"cache": {"dir": self.cache_dir}}
self.cache = Cache(self)
if cache_types:
self.cache.local.cache_types = cache_types
self.state = StateNoop()
@cached_property
def tree(self):
if self.scm:
return self.scm.get_tree(self.rev)
return LocalTree(self, {"url": self.root_dir})
def close(self):
if self.scm:
self.scm.close()
def find_out_by_relpath(self, path):
raise OutputNotFoundError(path, self)
@contextmanager
def open_by_relpath(self, path, mode="r", encoding=None, **kwargs):
"""Opens a specified resource as a file object."""
path_info = PathInfo(self.root_dir) / path
try:
with self.repo_tree.open(
path_info, mode=mode, encoding=encoding, **kwargs
) as fobj:
yield fobj
except FileNotFoundError as exc:
raise PathMissingError(path, self.url) from exc
def _cached_clone(url, rev, for_write=False):
"""Clone an external git repo to a temporary directory.
+30 -7
View File
@@ -1,26 +1,49 @@
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:
name: str
value: str
dir_info: dict = field(default=None, compare=False)
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):
if not 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)
((name, value),) = _d.items()
return cls(name, value, size=size, nfiles=nfiles)
def to_dict(self):
return {self.name: self.value} if self else {}
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):
+25 -1
View File
@@ -8,7 +8,7 @@ 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
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__
@@ -50,6 +50,10 @@ def get_dvc_info():
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:
@@ -63,6 +67,26 @@ def get_dvc_info():
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 = {
+6 -3
View File
@@ -15,10 +15,13 @@ from dvc.utils import format_link
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")
)
+16 -2
View File
@@ -42,6 +42,19 @@ def profile(enable, dump):
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.
@@ -72,8 +85,9 @@ def main(argv=None): # noqa: C901
logger.trace(args)
with profile(enable=args.cprofile, dump=args.cprofile_dump):
cmd = args.func(args)
ret = cmd.run()
with debug(args.pdb):
cmd = args.func(args)
ret = cmd.run()
except ConfigError:
logger.exception("configuration error")
ret = 251
+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)
+35 -12
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
@@ -61,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"
@@ -73,6 +75,7 @@ class BaseOutput:
PARAM_PLOT_TITLE = "title"
PARAM_PLOT_HEADER = "header"
PARAM_PERSIST = "persist"
PARAM_DESC = "desc"
METRIC_SCHEMA = Any(
None,
@@ -83,10 +86,10 @@ class BaseOutput:
},
)
DoesNotExistError = OutputDoesNotExistError
IsNotFileOrDirError = OutputIsNotFileOrDirError
IsStageFileError = OutputIsStageFileError
IsIgnoredError = OutputIsIgnoredError
DoesNotExistError = OutputDoesNotExistError # type: Type[DvcException]
IsNotFileOrDirError = OutputIsNotFileOrDirError # type: Type[DvcException]
IsStageFileError = OutputIsStageFileError # type: Type[DvcException]
IsIgnoredError = OutputIsIgnoredError # type: Type[DvcException]
sep = "/"
@@ -100,6 +103,8 @@ class BaseOutput:
metric=False,
plot=False,
persist=False,
checkpoint=False,
desc=None,
):
self._validate_output_path(path, stage)
# This output (and dependency) objects have too many paths/urls
@@ -124,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:
@@ -282,6 +289,9 @@ class BaseOutput:
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.hash_info)
@@ -293,6 +303,9 @@ class BaseOutput:
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
@@ -312,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):
@@ -326,7 +342,8 @@ class BaseOutput:
progress_callback=None,
relink=False,
filter_info=None,
allow_persist_missing=False,
allow_missing=False,
**kwargs,
):
if not self.use_cache:
if progress_callback:
@@ -343,9 +360,10 @@ class BaseOutput:
progress_callback=progress_callback,
relink=relink,
filter_info=filter_info,
**kwargs,
)
except CheckoutError:
if self.persist and allow_persist_missing:
if allow_missing or self.checkpoint:
return None
raise
@@ -443,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)
@@ -454,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
@@ -525,8 +541,15 @@ class BaseOutput:
my = self.dumpd()
other = out.dumpd()
my.pop(self.tree.PARAM_CHECKSUM)
other.pop(self.tree.PARAM_CHECKSUM)
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(
+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
+7
View File
@@ -0,0 +1,7 @@
from dvc.output.base import BaseOutput
from ..tree.webhdfs import WebHDFSTree
class WebHDFSOutput(BaseOutput):
TREE_CLS = WebHDFSTree
+321
View File
@@ -0,0 +1,321 @@
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, Dict, List, Optional
from funcy import join, lfilter
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,
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 VarsAlreadyLoaded(DvcException):
pass
class ResolveError(DvcException):
pass
def _format_preamble(msg, path, spacing=" "):
return f"failed to parse {msg} in '{path}':{spacing}"
def format_and_raise(exc, msg, path):
spacing = (
"\n"
if isinstance(exc, (ParseError, MergeError, VarsAlreadyLoaded))
else " "
)
message = _format_preamble(msg, 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: Dict[str, Optional[List[str]]] = {}
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): None}
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 (ContextError, VarsAlreadyLoaded) as exc:
format_and_raise(exc, "'vars'", self.relpath)
self.tracked_vars = {}
@staticmethod
def check_loaded(path, item, keys, skip_imports):
if not keys and isinstance(skip_imports[path], list):
raise VarsAlreadyLoaded(
f"cannot load '{item}' as it's partially loaded already"
)
elif keys and skip_imports[path] is None:
raise VarsAlreadyLoaded(
f"cannot partially load '{item}' as it's already loaded."
)
elif keys and isinstance(skip_imports[path], list):
if not set(keys).isdisjoint(set(skip_imports[path])):
raise VarsAlreadyLoaded(
f"cannot load '{item}' as it's partially loaded already"
)
def load_from_vars(
self,
context: "Context",
vars_: List,
wdir: PathInfo,
skip_imports: Dict[str, Optional[List[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, _, keys_str = item.partition(":")
keys = lfilter(bool, keys_str.split(","))
path_info = wdir / path
path = os.path.abspath(path_info)
if path in skip_imports:
if not keys and skip_imports[path] is None:
# allow specifying complete filepath multiple times
continue
self.check_loaded(path, item, keys, skip_imports)
context.merge_from(self.tree, path_info, select_keys=keys)
skip_imports[path] = keys if keys else None
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?
try:
self.load_from_vars(
context,
vars_,
wdir,
skip_imports=deepcopy(self.imported_files),
stage_name=name,
)
except VarsAlreadyLoaded as exc:
format_and_raise(exc, f"'stages.{name}.vars'", self.relpath)
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
)
self.tracked_vars[name] = self._get_vars(context)
return {name: resolved}
def _get_vars(self, context: Context):
tracked = defaultdict(dict)
for path, vars_ in context.tracked.items():
for var in vars_:
tracked[path][var] = context.select(var, unwrap=True)
return tracked
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)
+451
View File
@@ -0,0 +1,451 @@
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, select_keys=None) -> "Context":
file = relpath(path)
if not tree.exists(path):
raise ParamsFileNotFound(f"'{file}' does not exist")
_, ext = os.path.splitext(file)
loader = LOADERS[ext]
data = loader(path, tree=tree)
select_keys = select_keys or []
if select_keys:
try:
data = {key: data[key] for key in select_keys}
except KeyError as exc:
key, *_ = exc.args
raise ContextError(
f"could not find '{key}' in '{file}'"
) from exc
meta = Meta(source=file, local=False)
return cls(data, meta=meta)
def merge_from(
self, tree, path: PathInfo, overwrite=False, select_keys=None,
):
self.merge_update(
Context.load_from(tree, path, select_keys), 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()
+126
View File
@@ -0,0 +1,126 @@
import re
import typing
from functools import singledispatch
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))
@singledispatch
def to_str(obj):
return str(obj)
@to_str.register(bool)
def _(obj: bool):
return "true" if obj else "false"
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] + to_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)
+13 -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)
+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,
-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)
+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
+91 -258
View File
@@ -2,34 +2,37 @@ 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
from dvc.dvcfile import is_valid_filename
from dvc.exceptions import FileMissingError
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
from dvc.exceptions import (
NoOutputOrStageError,
NotDvcRepoError,
OutputNotFoundError,
)
from dvc.exceptions import NotDvcRepoError, OutputNotFoundError
from dvc.path_info import PathInfo
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_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):
def lock_repo(repo: "Repo"):
# pylint: disable=protected-access
depth = getattr(repo, "_lock_depth", 0)
repo._lock_depth = depth + 1
depth = repo._lock_depth
repo._lock_depth += 1
try:
if depth > 0:
@@ -65,12 +68,12 @@ class Repo:
from dvc.repo.fetch import fetch
from dvc.repo.freeze import freeze, unfreeze
from dvc.repo.gc import gc
from dvc.repo.get import get
from dvc.repo.get_url import get_url
from dvc.repo.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.install import install
from dvc.repo.ls import ls
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
@@ -80,6 +83,45 @@ class Repo:
from dvc.repo.status import status
from dvc.repo.update import update
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,
@@ -95,40 +137,30 @@ class Repo:
from dvc.repo.metrics import Metrics
from dvc.repo.params import Params
from dvc.repo.plots import Plots
from dvc.scm import SCM
from dvc.repo.stage import StageLoad
from dvc.stage.cache import StageCache
from dvc.state import State, StateNoop
from dvc.tree.local import LocalTree
from dvc.utils.fs import makedirs
try:
tree = scm.get_tree(rev) if rev else None
self.root_dir = self.find_root(root_dir, tree)
self.dvc_dir = os.path.join(self.root_dir, self.DVC_DIR)
self.tmp_dir = os.path.join(self.dvc_dir, "tmp")
makedirs(self.tmp_dir, exist_ok=True)
except NotDvcRepoError:
if not uninitialized:
raise
self.root_dir = SCM(root_dir or os.curdir).root_dir
self.dvc_dir = None
self.tmp_dir = None
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 = dict(use_dvcignore=True, dvcignore_root=self.root_dir)
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)
no_scm = self.config["core"].get("no_scm", False)
self.scm = scm if scm else SCM(self.root_dir, no_scm=no_scm)
self._scm = scm
# used by RepoTree to determine if it should traverse subrepos
self.subrepos = subrepos
self.cache = Cache(self)
self.cloud = DataCloud(self)
self.stage = StageLoad(self)
if scm or not self.dvc_dir:
self.lock = LockNoop()
@@ -144,7 +176,7 @@ class Repo:
# 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)
try:
@@ -157,13 +189,21 @@ class Repo:
self.metrics = Metrics(self)
self.plots = Plots(self)
self.params = Params(self)
self._lock_depth = 0
@cached_property
def scm(self):
from dvc.scm import SCM
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.
@@ -173,7 +213,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:
@@ -226,25 +266,6 @@ class Repo:
self.scm.ignore_list(flist)
def get_stage(self, path=None, name=None):
if not path:
path = PIPELINE_FILE
logger.debug("Assuming '%s' to be a stage inside '%s'", name, path)
dvcfile = Dvcfile(self, path)
return dvcfile.stages[name]
def get_stages(self, path=None, name=None):
if not path:
path = PIPELINE_FILE
logger.debug("Assuming '%s' to be a stage inside '%s'", name, path)
if name:
return [self.get_stage(path, name)]
dvcfile = Dvcfile(self, path)
return list(dvcfile.stages.values())
def check_modified_graph(self, new_stages):
"""Generate graph including the new stage to check for errors"""
# Building graph might be costly for the ones with many DVC-files,
@@ -260,101 +281,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)
def _collect_inside(self, path, graph):
import networkx as nx
stages = nx.dfs_postorder_nodes(graph)
return [stage for stage in stages if path_isin(stage.path, path)]
def collect(
self, target=None, with_deps=False, recursive=False, graph=None
):
if not target:
return list(graph) if graph else self.stages
if recursive and os.path.isdir(target):
return self._collect_inside(
os.path.abspath(target), graph or self.graph
)
path, name = parse_target(target)
stages = self.get_stages(path, name)
if not with_deps:
return stages
res = set()
for stage in stages:
res.update(self._collect_pipeline(stage, graph=graph))
return res
def _collect_pipeline(self, stage, graph=None):
import networkx as nx
pipeline = get_pipeline(get_pipelines(graph or self.graph), stage)
return nx.dfs_postorder_nodes(pipeline, stage)
def _collect_from_default_dvcfile(self, target):
dvcfile = Dvcfile(self, PIPELINE_FILE)
if dvcfile.exists():
return dvcfile.stages.get(target)
def collect_granular(
self, target=None, with_deps=False, recursive=False, graph=None
):
"""
Priority is in the order of following in case of ambiguity:
- .dvc file or .yaml file
- dir if recursive and directory exists
- stage_name
- output file
"""
if not target:
return [(stage, None) for stage in self.stages]
file, name = parse_target(target)
stages = []
# Optimization: do not collect the graph for a specific target
if not file:
# parsing is ambiguous when it does not have a colon
# or if it's not a dvcfile, as it can be a stage name
# in `dvc.yaml` or, an output in a stage.
logger.debug(
"Checking if stage '%s' is in '%s'", target, PIPELINE_FILE
)
if not (recursive and os.path.isdir(target)):
stage = self._collect_from_default_dvcfile(target)
if stage:
stages = (
self._collect_pipeline(stage) if with_deps else [stage]
)
elif not with_deps and is_valid_filename(file):
stages = self.get_stages(file, name)
if not stages:
if not (recursive and os.path.isdir(target)):
try:
(out,) = self.find_outs_by_path(target, strict=False)
filter_info = PathInfo(os.path.abspath(target))
return [(out.stage, filter_info)]
except OutputNotFoundError:
pass
try:
stages = self.collect(target, with_deps, recursive, graph)
except StageFileDoesNotExistError as exc:
# collect() might try to use `target` as a stage name
# and throw error that dvc.yaml does not exist, whereas it
# should say that both stage name and file does not exist.
if file and is_valid_filename(file):
raise
raise NoOutputOrStageError(target, exc.file) from exc
except StageNotFound as exc:
raise NoOutputOrStageError(target, exc.file) from exc
return [(stage, None) for stage in stages]
build_graph(self.stages + new_stages)
def used_cache(
self,
@@ -395,7 +322,7 @@ class Repo:
targets = targets or [None]
pairs = cat(
self.collect_granular(
self.stage.collect_granular(
target, recursive=recursive, with_deps=with_deps
)
for target in targets
@@ -419,114 +346,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,
OverlappingOutputPathsError,
StagePathAsOutputError,
)
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):
@@ -551,7 +381,8 @@ class Repo:
for root, dirs, files in self.tree.walk(self.root_dir):
for file_name in filter(is_valid_filename, files):
new_stages = self.get_stages(os.path.join(root, file_name))
file_path = os.path.join(root, file_name)
new_stages = self.stage.load_file(file_path)
stages.extend(new_stages)
outs.update(
out.fspath
@@ -619,6 +450,8 @@ class Repo:
self.scm.close()
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)
+69 -27
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):
from dvc.stage import Stage, create_stage
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, restore_meta
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)
@@ -144,8 +181,13 @@ def _create_stages(repo, targets, fname, pbar=None, external=False):
outs=[out],
external=external,
)
if stage:
restore_meta(stage)
if stage.can_be_skipped:
stage = None
else:
Dvcfile(repo, stage.path).remove()
if desc:
stage.outs[0].desc = desc
repo._reset() # pylint: disable=protected-access
+38 -23
View File
@@ -1,5 +1,6 @@
import logging
import os
from typing import TYPE_CHECKING, Set
from dvc.exceptions import (
CheckoutError,
@@ -11,6 +12,10 @@ from dvc.utils import relpath
from . import locked
if TYPE_CHECKING:
from . import Repo
from .stage import StageInfo
logger = logging.getLogger(__name__)
@@ -38,6 +43,34 @@ def get_all_files_numbers(pairs):
)
def _collect_pairs(
self: "Repo", targets, with_deps: bool, recursive: bool
) -> Set["StageInfo"]:
from dvc.stage.exceptions import (
StageFileBadNameError,
StageFileDoesNotExistError,
)
pairs: Set["StageInfo"] = set()
for target in targets:
try:
pairs.update(
self.stage.collect_granular(
target, with_deps=with_deps, recursive=recursive
)
)
except (
StageFileDoesNotExistError,
StageFileBadNameError,
NoOutputOrStageError,
) as exc:
if not target:
raise
raise CheckoutErrorSuggestGit(target) from exc
return pairs
@locked
def checkout(
self,
@@ -46,12 +79,9 @@ def checkout(
force=False,
relink=False,
recursive=False,
allow_persist_missing=False,
allow_missing=False,
**kwargs,
):
from dvc.stage.exceptions import (
StageFileBadNameError,
StageFileDoesNotExistError,
)
unused = []
stats = {
@@ -70,23 +100,7 @@ def checkout(
if isinstance(targets, str):
targets = [targets]
pairs = set()
for target in targets:
try:
pairs.update(
self.collect_granular(
target, with_deps=with_deps, recursive=recursive
)
)
except (
StageFileDoesNotExistError,
StageFileBadNameError,
NoOutputOrStageError,
) as exc:
if not target:
raise
raise CheckoutErrorSuggestGit(target) from exc
pairs = _collect_pairs(self, targets, with_deps, recursive)
total = get_all_files_numbers(pairs)
with Tqdm(
total=total, unit="file", desc="Checkout", disable=total == 0
@@ -97,7 +111,8 @@ def checkout(
progress_callback=pbar.update_msg,
relink=relink,
filter_info=filter_info,
allow_persist_missing=allow_persist_missing,
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)
+6 -2
View File
@@ -36,8 +36,12 @@ def prompt_to_commit(stage, changes, force=False):
@locked
def commit(self, target, with_deps=False, recursive=False, force=False):
stages = self.collect(target, with_deps=with_deps, recursive=recursive)
def commit(
self, target, with_deps=False, recursive=False, force=False,
):
stages = self.stage.collect(
target, with_deps=with_deps, recursive=recursive
)
for stage in stages:
changes = stage.changed_entries()
if any(changes):
+1 -1
View File
@@ -122,5 +122,5 @@ def _filter_missing(repo, paths):
metadata = repo_tree.metadata(path)
if metadata.is_dvc:
out = metadata.outs[0]
if out.status()[str(out)] == "not in cache":
if out.status().get(str(out)) == "not in cache":
yield path
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
import logging
import os
from dvc.exceptions import InvalidArgumentError
from dvc.repo import locked
from dvc.repo.experiments.executor import BaseExecutor
from dvc.repo.scm_context import scm_context
from dvc.utils.fs import remove
logger = logging.getLogger(__name__)
class ApplyError(InvalidArgumentError):
def __init__(self, rev):
super().__init__(
f"'{rev}' does not appear to be an experiment commit."
)
@locked
@scm_context
def apply(repo, rev, *args, **kwargs):
from git.exc import GitCommandError
from dvc.repo.checkout import checkout as dvc_checkout
from dvc.repo.experiments import BaselineMismatchError
exps = repo.experiments
try:
exps.check_baseline(rev)
except BaselineMismatchError as exc:
raise ApplyError(rev) from exc
stash_rev = rev in exps.stash_revs
if stash_rev:
branch = rev
else:
branch = exps.get_branch_containing(rev)
if not branch:
raise ApplyError(rev)
# Note that we don't use stash_workspace() here since we need finer control
# over the merge behavior when we unstash everything
if repo.scm.is_dirty(untracked_files=True):
logger.debug("Stashing workspace")
workspace = repo.scm.stash.push(include_untracked=True)
else:
workspace = None
repo.scm.repo.git.merge(branch, squash=True, no_commit=True)
if workspace:
try:
repo.scm.stash.apply(workspace)
except GitCommandError:
# if stash apply returns merge conflicts, prefer experiment
# changes over prior stashed changes
repo.scm.repo.git.checkout("--ours", "--", ".")
repo.scm.stash.drop()
repo.scm.repo.git.reset()
if stash_rev:
args_path = os.path.join(repo.tmp_dir, BaseExecutor.PACKED_ARGS_FILE)
if os.path.exists(args_path):
remove(args_path)
dvc_checkout(repo, **kwargs)
logger.info(
"Changes for experiment '%s' have been applied to your current "
"workspace.",
rev,
)
+96
View File
@@ -0,0 +1,96 @@
from typing import Optional
from dvc.exceptions import DvcException
# Experiment refs are stored according baseline git SHA:
# refs/exps/01/234abcd.../<exp_name>
EXPS_NAMESPACE = "refs/exps"
EXPS_STASH = f"{EXPS_NAMESPACE}/stash"
EXEC_NAMESPACE = f"{EXPS_NAMESPACE}/exec"
EXEC_CHECKPOINT = f"{EXEC_NAMESPACE}/EXEC_CHECKPOINT"
EXEC_BRANCH = f"{EXEC_NAMESPACE}/EXEC_BRANCH"
EXEC_BASELINE = f"{EXEC_NAMESPACE}/EXEC_BASELINE"
EXEC_HEAD = f"{EXEC_NAMESPACE}/EXEC_HEAD"
EXEC_MERGE = f"{EXEC_NAMESPACE}/EXEC_MERGE"
class UnchangedExperimentError(DvcException):
def __init__(self, rev):
super().__init__(f"Experiment unchanged from '{rev[:7]}'.")
self.rev = rev
class ExperimentExistsError(DvcException):
def __init__(self, name: str):
msg = (
"Reproduced experiment conflicts with existing experiment "
f"'{name}'. To overwrite the existing experiment run:\n\n"
"\tdvc exp run -f ...\n\n"
"To run this experiment with a different name run:\n\n"
f"\tdvc exp run -n <new_name> ...\n"
)
super().__init__(msg)
self.name = name
class CheckpointExistsError(DvcException):
def __init__(self, name: str):
msg = (
"Reproduced checkpoint experiment conflicts with existing "
f"experiment '{name}'. To restart (and overwrite) the existing "
"experiment run:\n\n"
"\tdvc exp run -f ...\n\n"
"To resume the existing experiment, run:\n\n"
f"\tdvc exp resume {name}\n"
)
super().__init__(msg)
self.name = name
class InvalidExpRefError(DvcException):
def __init__(self, ref):
super().__init__(f"'{ref}' is not a valid experiment refname.")
self.ref = ref
class ExpRefInfo:
namespace = EXPS_NAMESPACE
def __init__(
self, baseline_sha: Optional[str] = None, name: Optional[str] = None,
):
self.baseline_sha = baseline_sha
self.name = name
def __str__(self):
return "/".join(self.parts)
@property
def parts(self):
return (
(self.namespace,)
+ (
(self.baseline_sha[:2], self.baseline_sha[2:])
if self.baseline_sha
else ()
)
+ ((self.name,) if self.name else ())
)
@classmethod
def from_ref(cls, ref: str):
try:
parts = ref.split("/")
if (
len(parts) < 2
or len(parts) == 3
or len(parts) > 5
or "/".join(parts[:2]) != EXPS_NAMESPACE
):
InvalidExpRefError(ref)
except ValueError:
raise InvalidExpRefError(ref)
baseline_sha = parts[2] + parts[3] if len(parts) >= 4 else None
name = parts[4] if len(parts) == 5 else None
return cls(baseline_sha, name)
+74
View File
@@ -0,0 +1,74 @@
import logging
from dvc.exceptions import InvalidArgumentError
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
logger = logging.getLogger(__name__)
@locked
@scm_context
def branch(repo, exp_rev, branch_name, *args, **kwargs):
from dvc.repo.experiments.base import ExpRefInfo, InvalidExpRefError
from dvc.scm.base import RevError
from dvc.scm.git import Git
ref_info = None
if exp_rev.startswith("refs/"):
try:
ref_info = ExpRefInfo.from_ref(exp_rev)
except InvalidExpRefError:
pass
elif Git.is_sha(exp_rev):
try:
rev = repo.scm.resolve_rev(exp_rev)
ref = repo.experiments.get_branch_containing(rev)
if ref:
ref_info = ExpRefInfo.from_ref(ref)
except RevError:
pass
if not ref_info:
infos = list(repo.experiments.iter_ref_infos_by_name(exp_rev))
if len(infos) == 1:
ref_info = infos[0]
else:
current_rev = repo.scm.get_rev()
for info in infos:
if info.baseline_sha == current_rev:
ref_info = info
break
if not ref_info:
msg = [
f"Ambiguous experiment name '{exp_rev}' can refer to "
"multiple experiments. To create a branch use a full "
"experiment ref:",
"",
]
msg.extend([str(info) for info in infos])
raise InvalidArgumentError("\n".join(msg))
if not ref_info:
raise InvalidArgumentError(
f"'{exp_rev}' does not appear to be a valid experiment."
)
branch_ref = f"refs/heads/{branch_name}"
if repo.scm.get_ref(branch_ref):
raise InvalidArgumentError(
f"Git branch '{branch_name}' already exists."
)
target = repo.scm.get_ref(str(ref_info))
repo.scm.set_ref(
branch_ref,
target,
message=f"dvc: Created from experiment '{ref_info.name}'",
)
fmt = (
"Git branch '%s' has been created from experiment '%s'.\n"
"To switch to the new branch run:\n\n"
"\tgit checkout %s"
)
logger.info(fmt, branch_name, ref_info.name, branch_name)
-17
View File
@@ -1,17 +0,0 @@
import logging
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
logger = logging.getLogger(__name__)
@locked
@scm_context
def checkout(repo, rev, *args, **kwargs):
repo.experiments.checkout_exp(rev, *args, **kwargs)
logger.info(
"Changes for experiment '%s' have been applied to your current "
"workspace.",
rev,
)
+5 -7
View File
@@ -7,22 +7,20 @@ logger = logging.getLogger(__name__)
def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
from dvc.repo.experiments.show import _collect_experiment
from dvc.repo.experiments.show import _collect_experiment_commit
if repo.scm.no_commits:
return {}
if a_rev:
with repo.experiments.chdir():
old = _collect_experiment(repo.experiments.exp_dvc, a_rev)
old = _collect_experiment_commit(repo, a_rev)
else:
old = _collect_experiment(repo, "HEAD")
old = _collect_experiment_commit(repo, "HEAD")
if b_rev:
with repo.experiments.chdir():
new = _collect_experiment(repo.experiments.exp_dvc, b_rev)
new = _collect_experiment_commit(repo, b_rev)
else:
new = _collect_experiment(repo, "workspace")
new = _collect_experiment_commit(repo, "workspace")
with_unchanged = kwargs.pop("all", False)
+277 -100
View File
@@ -1,60 +1,134 @@
import logging
import os
import pickle
from functools import partial
from tempfile import TemporaryDirectory
from typing import Iterable, Optional
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, Union
from funcy import cached_property
from dvc.dvcfile import is_lock_file
from dvc.path_info import PathInfo
from dvc.repo import Repo
from dvc.repo.experiments.base import (
EXEC_BASELINE,
EXEC_BRANCH,
EXEC_CHECKPOINT,
EXEC_HEAD,
EXEC_MERGE,
EXEC_NAMESPACE,
EXPS_NAMESPACE,
EXPS_STASH,
ExpRefInfo,
UnchangedExperimentError,
)
from dvc.scm import SCM
from dvc.scm.git import Git
from dvc.stage import PipelineStage
from dvc.tree.base import BaseTree
from dvc.tree.local import LocalTree
from dvc.tree.repo import RepoTree
from dvc.stage.serialize import to_lockfile
from dvc.utils import dict_sha256
from dvc.utils.fs import remove
if TYPE_CHECKING:
from multiprocessing import Queue
logger = logging.getLogger(__name__)
class ExperimentExecutor:
class BaseExecutor:
"""Base class for executing experiments in parallel.
Args:
baseline_rev: baseline revision that this experiment is derived from.
src: source Git SCM instance.
dvc_dir: relpath to DVC root from SCM root.
Optional keyword args:
branch: Existing git branch for this experiment.
repro_args: Args to be passed into reproduce.
repro_kwargs: Keyword args to be passed into reproduce.
"""
PACKED_ARGS_FILE = "repro.dat"
def __init__(
self, baseline_rev: str, branch: Optional[str] = None, **kwargs,
self,
src: "Git",
dvc_dir: str,
root_dir: Optional[Union[str, PathInfo]] = None,
branch: Optional[str] = None,
name: Optional[str] = None,
**kwargs,
):
self.baseline_rev = baseline_rev
self.branch = branch
self.repro_args = kwargs.pop("repro_args", [])
self.repro_kwargs = kwargs.pop("repro_kwargs", {})
assert root_dir is not None
self._dvc_dir = dvc_dir
self.root_dir = root_dir
self._init_git(src, branch)
self.name = name
def _init_git(self, scm: "Git", branch: Optional[str] = None):
"""Init git repo and collect executor refs from the specified SCM."""
from dulwich.repo import Repo as DulwichRepo
DulwichRepo.init(os.fspath(self.root_dir))
cwd = os.getcwd()
os.chdir(self.root_dir)
try:
refspec = f"{EXEC_NAMESPACE}/"
scm.push_refspec(self.git_url, refspec, refspec)
if branch:
scm.push_refspec(self.git_url, branch, branch)
self.scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
if self.scm.get_ref(EXEC_CHECKPOINT):
self.scm.remove_ref(EXEC_CHECKPOINT)
# checkout EXEC_HEAD and apply EXEC_MERGE on top of it without
# committing
head = EXEC_BRANCH if branch else EXEC_HEAD
self.scm.checkout(head, detach=True)
self.scm.repo.git.merge(EXEC_MERGE, squash=True, no_commit=True)
self.scm.repo.git.reset()
self._prune_lockfiles()
finally:
os.chdir(cwd)
def _prune_lockfiles(self):
# NOTE: dirty DVC lock files must be restored to index state to
# avoid checking out incorrect persist or checkpoint outs
dirty = [diff.a_path for diff in self.scm.repo.index.diff(None)]
to_checkout = [fname for fname in dirty if is_lock_file(fname)]
self.scm.repo.index.checkout(paths=to_checkout, force=True)
untracked = self.scm.repo.untracked_files
to_remove = [fname for fname in untracked if is_lock_file(fname)]
for fname in to_remove:
remove(fname)
return (
len(dirty) - len(to_checkout) + len(untracked) - len(to_remove)
) != 0
@cached_property
def scm(self):
return SCM(self.root_dir)
@property
def tree(self) -> BaseTree:
def git_url(self) -> str:
raise NotImplementedError
@property
def dvc_dir(self) -> str:
return os.path.join(self.root_dir, self._dvc_dir)
@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 hash_exp(stages):
exp_data = {}
for stage in stages:
if isinstance(stage, PipelineStage):
exp_data.update(to_lockfile(stage))
return dict_sha256(exp_data)
def cleanup(self):
pass
self.scm.close()
del self.scm
# TODO: come up with better way to stash repro arguments
@staticmethod
@@ -73,82 +147,101 @@ class ExperimentExecutor:
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:
def unpack_repro_args(path):
with open(path, "rb") as fobj:
data = pickle.load(fobj)
return data["args"], data["kwargs"]
def fetch_exps(
self,
dest_scm: "Git",
force: bool = False,
on_diverged: Callable[[str], None] = None,
) -> Iterable[str]:
"""Fetch reproduced experiments into the specified SCM."""
refs = []
for ref in self.scm.iter_refs(base=EXPS_NAMESPACE):
if not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH:
refs.append(ref)
class LocalExecutor(ExperimentExecutor):
"""Local machine experiment executor."""
def on_diverged_ref(orig_ref, new_rev):
orig_rev = dest_scm.get_ref(orig_ref)
if dest_scm.diff(orig_rev, new_rev):
if force:
logger.debug(
"Replacing existing experiment '%s'",
os.fsdecode(orig_ref),
)
return True
if on_diverged:
checkpoint = self.scm.get_ref(EXEC_CHECKPOINT) is not None
on_diverged(orig_ref, checkpoint)
logger.debug(
"Reproduced existing experiment '%s'", os.fsdecode(orig_ref)
)
return False
def __init__(self, baseline_rev: str, **kwargs):
from dvc.repo import Repo
dvc_dir = kwargs.pop("dvc_dir")
cache_dir = kwargs.pop("cache_dir")
super().__init__(baseline_rev, **kwargs)
self.tmp_dir = TemporaryDirectory()
# init empty DVC repo (will be overwritten when input is uploaded)
Repo.init(root_dir=self.tmp_dir.name, no_scm=True)
logger.debug(
"Init local executor in dir '%s' with baseline '%s'.",
self.tmp_dir,
baseline_rev[:7],
# fetch experiments
dest_scm.fetch_refspecs(
self.git_url,
[f"{ref}:{ref}" for ref in refs],
on_diverged=on_diverged_ref,
force=force,
)
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
# update last run checkpoint (if it exists)
if self.scm.get_ref(EXEC_CHECKPOINT):
dest_scm.fetch_refspecs(
self.git_url,
[f"{EXEC_CHECKPOINT}:{EXEC_CHECKPOINT}"],
force=True,
)
return refs
def _config(self, cache_dir):
local_config = os.path.join(self.dvc_dir, "config.local")
logger.debug("Writing experiments local config '%s'", local_config)
with open(local_config, "w") as fobj:
fobj.write("[core]\n no_scm = true\n")
fobj.write(f"[cache]\n dir = {cache_dir}")
@cached_property
def dvc(self):
from dvc.repo import Repo
return Repo(self.dvc_dir)
@cached_property
def path_info(self):
return PathInfo(self.tmp_dir.name)
@property
def tree(self):
return self._tree
@staticmethod
def reproduce(dvc_dir, cwd=None, **kwargs):
"""Run dvc repro and return the result."""
from dvc.repo import Repo
from dvc.repo.experiments import hash_exp
@classmethod
def reproduce(
cls,
dvc_dir: str,
queue: "Queue",
rev: str,
cwd: Optional[str] = None,
name: Optional[str] = None,
) -> Tuple[bool, Optional[str]]:
"""Run dvc repro and return the result.
Returns tuple of (exp_hash, force) where exp_hash is the experiment
hash (or None on error) and force is a bool specifying whether or
not this experiment should force overwrite any existing duplicates.
"""
unchanged = []
queue.put((rev, os.getpid()))
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()
result = None
force = False
try:
logger.debug("Running repro in '%s'", cwd)
dvc = Repo(dvc_dir)
old_cwd = os.getcwd()
new_cwd = cwd if cwd else dvc.root_dir
os.chdir(new_cwd)
logger.debug("Running repro in '%s'", cwd)
args_path = os.path.join(
dvc.tmp_dir, BaseExecutor.PACKED_ARGS_FILE
)
if os.path.exists(args_path):
args, kwargs = BaseExecutor.unpack_repro_args(args_path)
remove(args_path)
else:
args = []
kwargs = {}
force = kwargs.get("force", False)
# NOTE: for checkpoint experiments we handle persist outs slightly
# differently than normal:
@@ -161,29 +254,113 @@ class LocalExecutor(ExperimentExecutor):
# be removed/does not yet exist) so that our executor workspace
# is not polluted with the (persistent) out from an unrelated
# experiment run
checkpoint = kwargs.pop("checkpoint", False)
dvc.checkout(allow_persist_missing=checkpoint, force=checkpoint)
stages = dvc.reproduce(on_unchanged=filter_pipeline, **kwargs)
dvc.checkout(force=True, quiet=True)
# We cannot use dvc.scm to make commits inside the executor since
# cached props are not picklable.
scm = Git()
checkpoint_func = partial(cls.checkpoint_callback, scm, name)
stages = dvc.reproduce(
*args,
on_unchanged=filter_pipeline,
checkpoint_func=checkpoint_func,
**kwargs,
)
exp_hash = cls.hash_exp(stages)
exp_rev = cls.commit(scm, exp_hash, exp_name=name)
if scm.get_ref(EXEC_CHECKPOINT):
scm.set_ref(EXEC_CHECKPOINT, exp_rev)
except UnchangedExperimentError:
pass
finally:
if old_cwd is not None:
if scm:
scm.close()
del scm
if old_cwd:
os.chdir(old_cwd)
# ideally we would return stages here like a normal repro() call, but
# stages is not currently picklable and cannot be returned across
# multiprocessing calls
return hash_exp(stages + unchanged)
return result, force
def collect_output(self) -> Iterable["PathInfo"]:
repo_tree = RepoTree(self.dvc)
yield from self.collect_files(self.tree, repo_tree)
@classmethod
def checkpoint_callback(
cls,
scm: "Git",
name: Optional[str],
unchanged: Iterable[PipelineStage],
stages: Iterable[PipelineStage],
):
try:
exp_hash = cls.hash_exp(stages + unchanged)
exp_rev = cls.commit(scm, exp_hash, exp_name=name)
scm.set_ref(EXEC_CHECKPOINT, exp_rev)
logger.info("Checkpoint experiment iteration '%s'.", exp_rev[:7])
except UnchangedExperimentError:
pass
@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)
@classmethod
def commit(cls, scm: "Git", exp_hash: str, exp_name: Optional[str] = None):
"""Commit stages as an experiment and return the commit SHA."""
rev = scm.get_rev()
if not scm.is_dirty(untracked_files=True):
logger.debug("No changes to commit")
raise UnchangedExperimentError(rev)
branch = scm.get_ref(EXEC_BRANCH, follow=False)
if branch:
old_ref = rev
logger.debug("Commit to current experiment branch '%s'", branch)
else:
baseline_rev = scm.get_ref(EXEC_BASELINE)
name = exp_name if exp_name else f"exp-{exp_hash[:5]}"
branch = str(ExpRefInfo(baseline_rev, name))
old_ref = None
logger.debug("Commit to new experiment branch '%s'", branch)
scm.repo.git.add(A=True)
scm.commit(f"dvc: commit experiment {exp_hash}")
new_rev = scm.get_rev()
scm.set_ref(branch, new_rev, old_ref=old_ref)
scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
return new_rev
class LocalExecutor(BaseExecutor):
"""Local machine experiment executor."""
def __init__(
self,
*args,
tmp_dir: Optional[str] = None,
cache_dir: Optional[str] = None,
**kwargs,
):
self._tmp_dir = TemporaryDirectory(dir=tmp_dir)
super().__init__(*args, root_dir=self._tmp_dir.name, **kwargs)
if cache_dir:
self._config(cache_dir)
logger.debug(
"Init local executor in dir '%s'", self._tmp_dir,
)
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}")
@property
def git_url(self) -> str:
root_dir = os.path.abspath(self.root_dir)
if os.name == "nt":
root_dir = root_dir.replace(os.sep, "/")
return f"file://{root_dir}"
def cleanup(self):
logger.debug("Removing tmpdir '%s'", self.tmp_dir)
self.tmp_dir.cleanup()
super().cleanup()
logger.debug("Removing tmpdir '%s'", self._tmp_dir)
self._tmp_dir.cleanup()
+55
View File
@@ -0,0 +1,55 @@
import logging
from typing import Optional
from dvc.repo import locked
from dvc.repo.experiments.base import (
EXEC_NAMESPACE,
EXPS_NAMESPACE,
EXPS_STASH,
ExpRefInfo,
)
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
removed = 0
for ref in repo.scm.iter_refs(EXPS_NAMESPACE):
if ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH:
continue
ref_info = ExpRefInfo.from_ref(ref)
if ref_info.baseline_sha not in keep_revs:
repo.scm.remove_ref(ref)
removed += 1
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.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.base 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)}
+55 -66
View File
@@ -1,84 +1,76 @@
import logging
from collections import OrderedDict, defaultdict
from datetime import datetime
from typing import Optional
from funcy import first
from dvc.repo import locked
from dvc.repo.experiments.base import ExpRefInfo
from dvc.repo.metrics.show import _collect_metrics, _read_metrics
from dvc.repo.params.show import _collect_configs, _read_params
logger = logging.getLogger(__name__)
def _collect_experiment(repo, rev, stash=False, sha_only=True):
from git.exc import GitCommandError
def _collect_experiment_commit(repo, rev, stash=False, sha_only=True):
res = defaultdict(dict)
for rev in repo.brancher(revs=[rev]):
if rev == "workspace":
res["timestamp"] = None
else:
commit = _resolve_commit(repo, rev)
commit = repo.scm.resolve_commit(rev)
res["timestamp"] = datetime.fromtimestamp(commit.committed_date)
configs = _collect_configs(repo)
configs = _collect_configs(repo, rev=rev)
params = _read_params(repo, configs, rev)
if params:
res["params"] = params
res["queued"] = stash
if not stash:
metrics = _collect_metrics(repo, None, False)
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
)
for refspec in ["refs/tags", "refs/heads"]:
name = repo.scm.describe(rev, base=refspec)
if name:
break
if not name:
if stash:
pass
else:
name = repo.experiments.get_exact_name(rev)
if name:
name = name.rsplit("/")[-1]
res["name"] = name
except GitCommandError:
pass
return res
def _resolve_commit(repo, rev):
from git.objects.tag import TagObject
commit = repo.scm.repo.rev_parse(rev)
if isinstance(commit, TagObject):
commit = commit.object
return commit
def _collect_checkpoint_experiment(repo, branch, baseline, **kwargs):
res = OrderedDict()
def _collect_experiment_branch(res, repo, branch, baseline, **kwargs):
exp_rev = repo.scm.resolve_rev(branch)
for rev in _branch_revs(repo, exp_rev, baseline):
res[rev] = _collect_experiment(repo, rev, **kwargs)
res[rev]["checkpoint_tip"] = exp_rev
prev = None
revs = list(repo.scm.branch_revs(exp_rev, baseline))
for rev in revs:
collected_exp = _collect_experiment_commit(repo, rev, **kwargs)
if len(revs) > 1:
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(collected_exp)
else:
exp = collected_exp
res[rev] = exp
prev = rev
if len(revs) > 1:
res[prev]["checkpoint_parent"] = baseline
return res
def _branch_revs(repo, branch_tip, baseline: Optional[str] = None):
"""Iterate over revisions in a given branch (from newest to oldest).
If baseline is set, iterator will stop when the specified revision is
reached.
"""
commit = _resolve_commit(repo, branch_tip)
while commit is not None:
yield commit.hexsha
commit = first(commit.parents)
if commit and commit.hexsha == baseline:
return
@locked
def show(
repo,
@@ -105,36 +97,33 @@ def show(
)
for rev in revs:
res[rev]["baseline"] = _collect_experiment(
res[rev]["baseline"] = _collect_experiment_commit(
repo, rev, sha_only=sha_only
)
# collect reproduced experiments
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 in revs:
with repo.experiments.chdir():
if m.group("checkpoint"):
checkpoint_exps = _collect_checkpoint_experiment(
repo.experiments.exp_dvc, exp_branch, rev
)
res[rev].update(checkpoint_exps)
else:
exp_rev = repo.experiments.scm.resolve_rev(exp_branch)
experiment = _collect_experiment(
repo.experiments.exp_dvc, exp_branch
)
res[rev][exp_rev] = experiment
if rev == "workspace":
continue
ref_info = ExpRefInfo(baseline_sha=rev)
commits = [
(ref, repo.scm.resolve_commit(ref))
for ref in repo.scm.iter_refs(base=str(ref_info))
]
for exp_ref, _ in sorted(
commits, key=lambda x: x[1].committed_date, reverse=True,
):
ref_info = ExpRefInfo.from_ref(exp_ref)
assert ref_info.baseline_sha == rev
_collect_experiment_branch(
res[rev], repo, exp_ref, rev, sha_only=sha_only
)
# 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
)
experiment = _collect_experiment_commit(
repo, stash_rev, stash=True
)
res[entry.baseline_rev][stash_rev] = experiment
return res
+7 -5
View File
@@ -1,12 +1,14 @@
import typing
from . import locked
if typing.TYPE_CHECKING:
from . import Repo
@locked
def _set(repo, target, frozen):
from dvc.utils import parse_target
path, name = parse_target(target)
stage = repo.get_stage(path, name)
def _set(repo: "Repo", target, frozen):
stage = repo.stage.get_target(target)
stage.frozen = frozen
stage.dvcfile.dump(stage, update_lock=False)
+13 -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(
@@ -76,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.")
-1
View File
@@ -18,7 +18,6 @@ class GetDVCFileError(DvcException):
)
@staticmethod
def get(url, path, out=None, rev=None):
from dvc.dvcfile import is_valid_filename
from dvc.external_repo import external_repo
+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()
+120 -2
View File
@@ -1,4 +1,14 @@
def check_acyclic(graph):
from typing import TYPE_CHECKING, Iterator, List, Set
from dvc.utils.fs import path_isin
if TYPE_CHECKING:
from networkx import DiGraph
from dvc.stage import Stage
def check_acyclic(graph: "DiGraph") -> None:
import networkx as nx
from dvc.exceptions import CyclicGraphError
@@ -8,7 +18,7 @@ def check_acyclic(graph):
except nx.NetworkXNoCycle:
return
stages = set()
stages: Set["Stage"] = set()
for from_node, to_node, _ in edges:
stages.add(from_node)
stages.add(to_node)
@@ -26,3 +36,111 @@ def get_pipelines(G):
import networkx as nx
return [G.subgraph(c).copy() for c in nx.weakly_connected_components(G)]
def collect_pipeline(stage: "Stage", graph: "DiGraph") -> Iterator["Stage"]:
import networkx as nx
pipeline = get_pipeline(get_pipelines(graph), stage)
return nx.dfs_postorder_nodes(pipeline, stage)
def collect_inside_path(path: str, graph: "DiGraph") -> List["Stage"]:
import networkx as nx
stages = nx.dfs_postorder_nodes(graph)
return [stage for stage in stages if path_isin(stage.path, path)]
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, fname=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, fname=fname, erepo=erepo, frozen=True)
return self.imp_url(
path, out=out, fname=fname, erepo=erepo, frozen=True, **kwargs
)
+14 -4
View File
@@ -11,10 +11,17 @@ 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
from dvc.stage import Stage, create_stage, restore_meta
out = resolve_output(url, out)
path, wdir, out = resolve_paths(self, out)
@@ -36,10 +43,13 @@ def imp_url(
outs=[out],
erepo=erepo,
)
if stage is None:
restore_meta(stage)
if stage.can_be_skipped:
return None
if desc:
stage.outs[0].desc = desc
dvcfile = Dvcfile(self, stage.path)
dvcfile.remove()
+10 -36
View File
@@ -1,45 +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
from dvc.utils import format_link as fmt_link
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"
+ 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)
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
"""
Creates an empty repo on the given directory -- basically a
@@ -72,19 +45,22 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
scm = SCM(root_dir, search_parent_directories=subdir, no_scm=no_scm)
except SCMError:
raise InitError(
"{repo} is not tracked by any supported SCM tool (e.g. Git). "
f"{root_dir} is not tracked by any supported SCM tool (e.g. Git). "
"Use `--no-scm` if you don't want to use any SCM or "
"`--subdir` if initializing inside a subdirectory of a parent SCM "
"repository.".format(repo=root_dir)
"repository."
)
if scm.is_ignored(dvc_dir):
raise InitError(
f"{dvc_dir} is ignored by your SCM tool. \n"
"Make sure that it's tracked, "
"for example, by adding '!.dvc' to .gitignore."
)
if os.path.isdir(dvc_dir):
if not force:
raise InitError(
"'{repo}' exists. Use `-f` to force.".format(
repo=relpath(dvc_dir)
)
)
raise InitError(f"'{relpath(dvc_dir)}' exists. Use `-f` to force.")
remove(dvc_dir)
@@ -110,6 +86,4 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
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
+2 -5
View File
@@ -4,7 +4,6 @@ from dvc.exceptions import PathMissingError
from dvc.path_info import PathInfo
@staticmethod
def ls(
url, path=None, rev=None, recursive=None, dvc_only=False,
):
@@ -38,7 +37,7 @@ def ls(
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)
@@ -51,12 +50,10 @@ def ls(
return ret_list
def _ls(repo, path_info, recursive=None, dvc_only=False):
def _ls(tree, path_info, recursive=None, dvc_only=False):
def onerror(exc):
raise exc
tree = repo.repo_tree
infos = []
try:
for root, dirs, files in tree.walk(
+21 -39
View File
@@ -1,41 +1,28 @@
import logging
import os
from dvc.exceptions import NoMetricsError
from dvc.path_info import PathInfo
from dvc.exceptions import NoMetricsFoundError, NoMetricsParsedError
from dvc.repo import locked
from dvc.repo.collect import collect
from dvc.scm.base import SCMError
from dvc.tree.repo import RepoTree
from dvc.utils.serialize import YAMLFileCorruptedError, load_yaml
logger = logging.getLogger(__name__)
def _collect_metrics(repo, targets, recursive):
def _is_metric(out):
return bool(out.metric)
if targets:
target_infos = [
PathInfo(os.path.abspath(target)) for target in targets
]
tree = RepoTree(repo)
rec_files = []
if recursive:
for target_info in target_infos:
if tree.isdir(target_info):
rec_files.extend(list(tree.walk_files(target_info)))
result = [t for t in target_infos if tree.isfile(t)]
result.extend(rec_files)
return result
metrics = set()
for stage in repo.stages:
for out in stage.outs:
if not out.metric:
continue
metrics.add(out.path_info)
return list(metrics)
def _collect_metrics(repo, targets, revision, recursive):
metrics, path_infos = collect(
repo,
targets=targets,
output_filter=_is_metric,
recursive=recursive,
rev=revision,
)
return [m.path_info for m in metrics] + list(path_infos)
def _extract_metrics(metrics, path, rev):
@@ -105,7 +92,7 @@ def show(
all_tags=all_tags,
all_commits=all_commits,
):
metrics = _collect_metrics(repo, targets, recursive)
metrics = _collect_metrics(repo, targets, rev, recursive)
if not metrics_found and metrics:
metrics_found = True
@@ -117,22 +104,17 @@ def show(
if not res:
if metrics_found:
msg = (
"Could not parse metric files. Use `-v` option to see more "
"details."
)
raise NoMetricsParsedError("metrics")
else:
msg = (
"no metric files in this repository. Use `-m/-M` options for "
"`dvc run` to mark stage outputs as metrics."
)
raise NoMetricsError(msg)
raise NoMetricsFoundError("metrics", "-m/-M")
# Hide workspace metrics if they are the same as in the active branch
try:
active_branch = repo.scm.active_branch()
except TypeError:
pass # Detached head
except (TypeError, SCMError):
# TypeError - detached head
# SCMError - no repo case
pass
else:
if res.get("workspace") == res.get(active_branch):
res.pop("workspace", None)
+29 -9
View File
@@ -1,11 +1,18 @@
import logging
from typing import TYPE_CHECKING, List
from dvc.dependency.param import ParamsDependency
from dvc.exceptions import DvcException
from dvc.path_info import PathInfo
from dvc.repo import locked
from dvc.repo.collect import collect
from dvc.stage import PipelineStage
from dvc.utils.serialize import LOADERS, ParseError
if TYPE_CHECKING:
from dvc.output.base import BaseOutput
from dvc.repo import Repo
logger = logging.getLogger(__name__)
@@ -13,15 +20,14 @@ class NoParamsError(DvcException):
pass
def _collect_configs(repo):
configs = set()
configs.add(PathInfo(repo.root_dir) / ParamsDependency.DEFAULT_PARAMS_FILE)
for stage in repo.stages:
for dep in stage.deps:
if not isinstance(dep, ParamsDependency):
continue
def _is_params(dep: "BaseOutput"):
return isinstance(dep, ParamsDependency)
configs.add(dep.path_info)
def _collect_configs(repo: "Repo", rev) -> List[PathInfo]:
params, _ = collect(repo, deps=True, output_filter=_is_params, rev=rev)
configs = {p.path_info for p in params}
configs.add(PathInfo(repo.root_dir) / ParamsDependency.DEFAULT_PARAMS_FILE)
return list(configs)
@@ -44,13 +50,27 @@ def _read_params(repo, configs, rev):
return res
def _add_vars(repo, configs, params):
for stage in repo.stages:
if isinstance(stage, PipelineStage) and stage.tracked_vars:
for file, vars_ in stage.tracked_vars.items():
# `params` file are shown regardless of `tracked` or not
# to reduce noise and duplication, they are skipped
if file in configs:
continue
params[file] = params.get(file, {})
params[file].update(vars_)
@locked
def show(repo, revs=None):
res = {}
for branch in repo.brancher(revs=revs):
configs = _collect_configs(repo)
configs = _collect_configs(repo, branch)
params = _read_params(repo, configs, branch)
_add_vars(repo, configs, params)
if params:
res[branch] = params
+37 -36
View File
@@ -1,10 +1,14 @@
import logging
import os
from funcy import cached_property, first, project
from dvc.exceptions import DvcException, NoPlotsError
from dvc.path_info import PathInfo
from dvc.exceptions import (
DvcException,
NoMetricsFoundError,
NoMetricsParsedError,
)
from dvc.repo.collect import collect
from dvc.repo.plots.data import PlotParsingError
from dvc.schema import PLOT_PROPS
from dvc.tree.repo import RepoTree
from dvc.utils import relpath
@@ -72,10 +76,24 @@ class Plots:
# Merge data by plot file and apply overriding props
plots = _prepare_plots(data, revs, props)
return {
datafile: _render(datafile, desc["data"], desc["props"], templates)
for datafile, desc in plots.items()
}
result = {}
for datafile, desc in plots.items():
try:
result[datafile] = _render(
datafile, desc["data"], desc["props"], templates
)
except PlotParsingError as e:
logger.debug(
"failed to read '%s' on '%s'",
e.path,
e.revision,
exc_info=True,
)
if not any(result.values()):
raise NoMetricsParsedError("plots")
return result
def show(self, targets=None, revs=None, props=None, templates=None):
from .data import NoMetricInHistoryError
@@ -85,12 +103,13 @@ class Plots:
# If any mentioned plot doesn't have any data then that's an error
targets = [targets] if isinstance(targets, str) else targets or []
for target in targets:
if not any("data" in d[target] for d in data.values()):
rpath = relpath(target, self.repo.root_dir)
if not any("data" in d[rpath] for d in data.values()):
raise NoMetricInHistoryError(target)
# No data at all is a special error with a special message
if not data:
raise NoPlotsError()
raise NoMetricsFoundError("plots", "--plots/--plots-no-cache")
if templates is None:
templates = self.templates
@@ -149,34 +168,16 @@ class Plots:
return PlotTemplates(self.repo.dvc_dir)
def _is_plot(out):
return bool(out.plot)
def _collect_plots(repo, targets=None, rev=None):
plots = {out for stage in repo.stages for out in stage.outs if out.plot}
def to_result(plots):
return {plot.path_info: _plot_props(plot) for plot in plots}
if not targets:
return to_result(plots)
target_infos = {PathInfo(os.path.abspath(target)) for target in targets}
target_plots = set()
for p in plots:
if p.path_info in target_infos:
target_plots.add(p)
target_infos.remove(p.path_info)
tree = RepoTree(repo)
result = to_result(target_plots)
for t in target_infos:
if tree.isfile(t):
result[t] = {}
else:
logger.warning(
"'%s' was not found at: '%s'. It will not be plotted.", t, rev,
)
plots, path_infos = collect(
repo, output_filter=_is_plot, targets=targets, rev=rev
)
result = {plot.path_info: _plot_props(plot) for plot in plots}
result.update({path_info: {} for path_info in path_infos})
return result
+19 -6
View File
@@ -1,14 +1,13 @@
import csv
import io
import json
import os
from collections import OrderedDict
from copy import copy
from funcy import first
from funcy import first, reraise
from dvc.exceptions import DvcException
from dvc.utils.serialize import loads_yaml
from dvc.utils.serialize import ParseError, parse_json, parse_yaml
class PlotMetricTypeError(DvcException):
@@ -32,6 +31,14 @@ class NoMetricInHistoryError(DvcException):
super().__init__(f"Could not find '{path}'.")
class PlotParsingError(ParseError):
def __init__(self, path, revision):
self.path = path
self.revision = revision
super().__init__(path, f"revision: {revision}")
def plot_data(filename, revision, content):
_, extension = os.path.splitext(filename.lower())
if extension == ".json":
@@ -159,7 +166,11 @@ class PlotData:
return [_filter_fields, _append_index, _append_revision]
def to_datapoints(self, **kwargs):
data = self.raw(**kwargs)
with reraise(
[ParseError, csv.Error],
PlotParsingError(self.filename, self.revision),
):
data = self.raw(**kwargs)
for data_proc in self._processors():
data = data_proc(
@@ -170,7 +181,9 @@ class PlotData:
class JSONPlotData(PlotData):
def raw(self, **kwargs):
return json.loads(self.content, object_pairs_hook=OrderedDict)
return parse_json(
self.content, self.filename, object_pairs_hook=OrderedDict
)
def _processors(self):
parent_processors = super()._processors()
@@ -207,7 +220,7 @@ class CSVPlotData(PlotData):
class YAMLPlotData(PlotData):
def raw(self, **kwargs):
return loads_yaml(self.content, typ="rt")
return parse_yaml(self.content, self.filename, typ="rt")
def _processors(self):
parent_processors = super()._processors()
+353 -37
View File
@@ -1,5 +1,6 @@
import json
import os
from typing import Any, Dict, Optional
from funcy import cached_property
@@ -29,8 +30,8 @@ class Template:
EXTENSION = ".json"
ANCHOR = "<DVC_METRIC_{}>"
DEFAULT_CONTENT = None
DEFAULT_NAME = None
DEFAULT_CONTENT: Optional[Dict[str, Any]] = None
DEFAULT_NAME: Optional[str] = None
def __init__(self, content=None, name=None):
if content:
@@ -106,13 +107,15 @@ class Template:
raise NoFieldInDataError(field)
class DefaultLinearTemplate(Template):
class DefaultTemplate(Template):
DEFAULT_NAME = "default"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"width": 300,
"height": 300,
"mark": {"type": "line"},
"encoding": {
"x": {
@@ -131,56 +134,266 @@ class DefaultLinearTemplate(Template):
}
class DefaultConfusionTemplate(Template):
class ConfusionTemplate(Template):
DEFAULT_NAME = "confusion"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"mark": "rect",
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("x_label"),
"facet": {"field": "rev", "type": "nominal"},
"spec": {
"transform": [
{
"aggregate": [{"op": "count", "as": "xy_count"}],
"groupby": [Template.anchor("y"), Template.anchor("x")],
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("y")],
"key": Template.anchor("x"),
"value": 0,
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("x")],
"key": Template.anchor("y"),
"value": 0,
},
{
"joinaggregate": [
{"op": "max", "field": "xy_count", "as": "max_count"}
],
"groupby": [],
},
{
"calculate": "datum.xy_count / datum.max_count",
"as": "percent_of_max",
},
],
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("y_label"),
},
},
"y": {
"field": Template.anchor("y"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("y_label"),
},
"color": {"aggregate": "count", "type": "quantitative"},
"facet": {"field": "rev", "type": "nominal"},
"layer": [
{
"mark": "rect",
"width": 300,
"height": 300,
"encoding": {
"color": {
"field": "xy_count",
"type": "quantitative",
"title": "",
"scale": {"domainMin": 0, "nice": True},
}
},
},
{
"mark": "text",
"encoding": {
"text": {"field": "xy_count", "type": "quantitative"},
"color": {
"condition": {
"test": "datum.percent_of_max > 0.5",
"value": "white",
},
"value": "black",
},
},
},
],
},
}
class DefaultScatterTemplate(Template):
DEFAULT_NAME = "scatter"
class NormalizedConfusionTemplate(Template):
DEFAULT_NAME = "confusion_normalized"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"mark": "point",
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
"title": Template.anchor("x_label"),
"facet": {"field": "rev", "type": "nominal"},
"spec": {
"transform": [
{
"aggregate": [{"op": "count", "as": "xy_count"}],
"groupby": [Template.anchor("y"), Template.anchor("x")],
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("y")],
"key": Template.anchor("x"),
"value": 0,
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("x")],
"key": Template.anchor("y"),
"value": 0,
},
{
"joinaggregate": [
{"op": "sum", "field": "xy_count", "as": "sum_y"}
],
"groupby": [Template.anchor("y")],
},
{
"calculate": "datum.xy_count / datum.sum_y",
"as": "percent_of_y",
},
],
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("y_label"),
},
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
"title": Template.anchor("y_label"),
"scale": {"zero": False},
},
"color": {"field": "rev", "type": "nominal"},
"layer": [
{
"mark": "rect",
"width": 300,
"height": 300,
"encoding": {
"color": {
"field": "percent_of_y",
"type": "quantitative",
"title": "",
"scale": {"domain": [0, 1]},
}
},
},
{
"mark": "text",
"encoding": {
"text": {
"field": "percent_of_y",
"type": "quantitative",
"format": ".2f",
},
"color": {
"condition": {
"test": "datum.percent_of_y > 0.5",
"value": "white",
},
"value": "black",
},
},
},
],
},
}
class ScatterTemplate(Template):
DEFAULT_NAME = "scatter"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"width": 300,
"height": 300,
"layer": [
{
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
"title": Template.anchor("y_label"),
"scale": {"zero": False},
},
"color": {"field": "rev", "type": "nominal"},
},
"layer": [
{"mark": "point"},
{
"selection": {
"label": {
"type": "single",
"nearest": True,
"on": "mouseover",
"encodings": ["x"],
"empty": "none",
"clear": "mouseout",
}
},
"mark": "point",
"encoding": {
"opacity": {
"condition": {
"selection": "label",
"value": 1,
},
"value": 0,
}
},
},
],
},
{
"transform": [{"filter": {"selection": "label"}}],
"layer": [
{
"encoding": {
"text": {
"type": "quantitative",
"field": Template.anchor("y"),
},
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
},
},
"layer": [
{
"mark": {
"type": "text",
"align": "left",
"dx": 5,
"dy": -5,
},
"encoding": {
"color": {
"type": "nominal",
"field": "rev",
}
},
}
],
},
],
},
],
}
class SmoothLinearTemplate(Template):
DEFAULT_NAME = "smooth"
@@ -214,12 +427,115 @@ class SmoothLinearTemplate(Template):
}
class LinearTemplate(Template):
DEFAULT_NAME = "linear"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"width": 300,
"height": 300,
"layer": [
{
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
"title": Template.anchor("y_label"),
"scale": {"zero": False},
},
"color": {"field": "rev", "type": "nominal"},
},
"layer": [
{"mark": "line"},
{
"selection": {
"label": {
"type": "single",
"nearest": True,
"on": "mouseover",
"encodings": ["x"],
"empty": "none",
"clear": "mouseout",
}
},
"mark": "point",
"encoding": {
"opacity": {
"condition": {
"selection": "label",
"value": 1,
},
"value": 0,
}
},
},
],
},
{
"transform": [{"filter": {"selection": "label"}}],
"layer": [
{
"mark": {"type": "rule", "color": "gray"},
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
}
},
},
{
"encoding": {
"text": {
"type": "quantitative",
"field": Template.anchor("y"),
},
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
},
},
"layer": [
{
"mark": {
"type": "text",
"align": "left",
"dx": 5,
"dy": -5,
},
"encoding": {
"color": {
"type": "nominal",
"field": "rev",
}
},
}
],
},
],
},
],
}
class PlotTemplates:
TEMPLATES_DIR = "plots"
TEMPLATES = [
DefaultLinearTemplate,
DefaultConfusionTemplate,
DefaultScatterTemplate,
DefaultTemplate,
LinearTemplate,
ConfusionTemplate,
NormalizedConfusionTemplate,
ScatterTemplate,
SmoothLinearTemplate,
]
+6 -4
View File
@@ -1,15 +1,17 @@
import logging
import typing
from ..utils import parse_target
from . import locked
if typing.TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
@locked
def remove(self, target, outs=False):
path, name = parse_target(target)
stages = self.get_stages(path, name)
def remove(self: "Repo", target: str, outs: bool = False):
stages = self.stage.from_target(target)
for stage in stages:
stage.remove(remove_outs=outs, force=outs)
+69 -104
View File
@@ -1,13 +1,17 @@
import logging
import typing
from functools import partial
from dvc.exceptions import InvalidArgumentError, ReproductionError
from dvc.repo.experiments import UnchangedExperimentError
from dvc.repo.scm_context import scm_context
from dvc.stage.run import CheckpointKilledError
from . import locked
from .graph import get_pipeline, get_pipelines
if typing.TYPE_CHECKING:
from . import Repo
logger = logging.getLogger(__name__)
@@ -17,8 +21,15 @@ def _reproduce_stage(stage, **kwargs):
repro_callback([stage])
checkpoint_func = kwargs.pop("checkpoint_func", None)
if checkpoint_func:
kwargs["checkpoint_func"] = partial(_run_callback, checkpoint_func)
if stage.is_checkpoint:
if checkpoint_func:
kwargs["checkpoint_func"] = partial(_run_callback, checkpoint_func)
else:
logger.warning(
"Checkpoint stages are not fully supported in 'dvc repro'. "
"Checkpoint stages should be reproduced with 'dvc exp run' "
"or 'dvc exp resume'."
)
if stage.frozen and not stage.is_import:
logger.warning(
@@ -68,47 +79,21 @@ def _get_active_graph(G):
@locked
@scm_context
def reproduce(
self,
self: "Repo",
target=None,
recursive=False,
pipeline=False,
all_pipelines=False,
**kwargs,
):
from dvc.utils import parse_target
glob = kwargs.pop("glob", False)
accept_group = not glob
assert target is None or isinstance(target, str)
if not target and not all_pipelines:
raise InvalidArgumentError(
"Neither `target` nor `--all-pipelines` are specified."
)
experiment = kwargs.pop("experiment", False)
params = _parse_params(kwargs.pop("params", []))
queue = kwargs.pop("queue", False)
run_all = kwargs.pop("run_all", False)
jobs = kwargs.pop("jobs", 1)
checkpoint = kwargs.pop("checkpoint", False)
checkpoint_continue = kwargs.pop("checkpoint_continue", None)
if (experiment or run_all) and self.experiments:
try:
return _reproduce_experiments(
self,
target=target,
recursive=recursive,
all_pipelines=all_pipelines,
params=params,
queue=queue,
run_all=run_all,
jobs=jobs,
checkpoint=checkpoint,
checkpoint_continue=checkpoint_continue,
**kwargs,
)
except UnchangedExperimentError:
# If experiment contains no changes, just run regular repro
pass
interactive = kwargs.get("interactive", False)
if not interactive:
kwargs["interactive"] = self.config["core"].get("interactive", False)
@@ -116,12 +101,11 @@ def reproduce(
active_graph = _get_active_graph(self.graph)
active_pipelines = get_pipelines(active_graph)
path, name = parse_target(target)
if pipeline or all_pipelines:
if all_pipelines:
pipelines = active_pipelines
else:
stage = self.get_stage(path, name)
stage = self.stage.get_target(target)
pipelines = [get_pipeline(active_pipelines, stage)]
targets = []
@@ -130,44 +114,17 @@ def reproduce(
if pipeline.in_degree(stage) == 0:
targets.append(stage)
else:
targets = self.collect(target, recursive=recursive, graph=active_graph)
targets = self.stage.collect(
target,
recursive=recursive,
graph=active_graph,
accept_group=accept_group,
glob=glob,
)
return _reproduce_stages(active_graph, targets, **kwargs)
def _parse_params(path_params):
from ruamel.yaml import YAMLError
from dvc.dependency.param import ParamsDependency
from dvc.utils.flatten import unflatten
from dvc.utils.serialize import loads_yaml
ret = {}
for path_param in path_params:
path, _, params_str = path_param.rpartition(":")
# remove empty strings from params, on condition such as `-p "file1:"`
params = {}
for param_str in filter(bool, params_str.split(",")):
try:
# interpret value strings using YAML rules
key, value = param_str.split("=")
params[key] = loads_yaml(value)
except (ValueError, YAMLError):
raise InvalidArgumentError(
f"Invalid param/value pair '{param_str}'"
)
if not path:
path = ParamsDependency.DEFAULT_PARAMS_FILE
ret[path] = unflatten(params)
return ret
def _reproduce_experiments(repo, run_all=False, jobs=1, **kwargs):
if run_all:
return repo.experiments.reproduce_queued(jobs=jobs)
return repo.experiments.reproduce_one(**kwargs)
def _reproduce_stages(
G, stages, downstream=False, single_item=False, on_unchanged=None, **kwargs
):
@@ -206,6 +163,49 @@ def _reproduce_stages(
The derived evaluation of _downstream_ B would be: [B, D, E]
"""
pipeline = _get_pipeline(G, stages, downstream, single_item)
force_downstream = kwargs.pop("force_downstream", False)
result = []
unchanged = []
# `ret` is used to add a cosmetic newline.
ret = []
checkpoint_func = kwargs.pop("checkpoint_func", None)
for stage in pipeline:
if ret:
logger.info("")
if checkpoint_func:
kwargs["checkpoint_func"] = partial(
_repro_callback, checkpoint_func, unchanged
)
try:
ret = _reproduce_stage(stage, **kwargs)
if len(ret) == 0:
unchanged.extend([stage])
elif force_downstream:
# NOTE: we are walking our pipeline from the top to the
# bottom. If one stage is changed, it will be reproduced,
# which tells us that we should force reproducing all of
# the other stages down below, even if their direct
# dependencies didn't change.
kwargs["force"] = True
if ret:
result.extend(ret)
except CheckpointKilledError:
raise
except Exception as exc:
raise ReproductionError(stage.relpath) from exc
if on_unchanged is not None:
on_unchanged(unchanged)
return result
def _get_pipeline(G, stages, downstream, single_item):
import networkx as nx
if single_item:
@@ -234,42 +234,7 @@ def _reproduce_stages(
if stage not in pipeline:
pipeline.append(stage)
force_downstream = kwargs.pop("force_downstream", False)
result = []
unchanged = []
# `ret` is used to add a cosmetic newline.
ret = []
for stage in pipeline:
if ret:
logger.info("")
checkpoint_func = kwargs.pop("checkpoint_func", None)
if checkpoint_func:
kwargs["checkpoint_func"] = partial(
_repro_callback, checkpoint_func, unchanged
)
try:
ret = _reproduce_stage(stage, **kwargs)
if len(ret) == 0:
unchanged.extend([stage])
elif force_downstream:
# NOTE: we are walking our pipeline from the top to the
# bottom. If one stage is changed, it will be reproduced,
# which tells us that we should force reproducing all of
# the other stages down below, even if their direct
# dependencies didn't change.
kwargs["force"] = True
if ret:
result.extend(ret)
except Exception as exc:
raise ReproductionError(stage.relpath) from exc
if on_unchanged is not None:
on_unchanged(unchanged)
return result
return pipeline
def _repro_callback(experiments_callback, unchanged, stages):
+4 -2
View File
@@ -48,6 +48,7 @@ def _get_file_path(kwargs):
kwargs.get("plots_no_cache", []),
kwargs.get("outs_persist", []),
kwargs.get("outs_persist_no_cache", []),
kwargs.get("checkpoints", []),
)
)
@@ -77,7 +78,7 @@ def _check_stage_exists(dvcfile, stage):
@scm_context
def run(self, fname=None, no_exec=False, single_stage=False, **kwargs):
from dvc.dvcfile import PIPELINE_FILE, Dvcfile
from dvc.stage import Stage, create_stage
from dvc.stage import Stage, create_stage, restore_meta
if not kwargs.get("cmd"):
raise InvalidArgumentError("command is not specified")
@@ -112,7 +113,8 @@ def run(self, fname=None, no_exec=False, single_stage=False, **kwargs):
stage = create_stage(
stage_cls, repo=self, path=path, params=params, **kwargs
)
if stage is None:
restore_meta(stage)
if kwargs.get("run_cache", True) and stage.can_be_skipped:
return None
dvcfile = Dvcfile(self, stage.path)
+318
View File
@@ -0,0 +1,318 @@
import fnmatch
import logging
import os
import typing
from contextlib import suppress
from typing import Iterable, List, NamedTuple, Optional, Set, Tuple
from dvc.dvcfile import PIPELINE_FILE, Dvcfile, is_valid_filename
from dvc.exceptions import NoOutputOrStageError, OutputNotFoundError
from dvc.path_info import PathInfo
from dvc.repo.graph import collect_inside_path, collect_pipeline
from dvc.stage.exceptions import StageFileDoesNotExistError, StageNotFound
from dvc.utils import parse_target
logger = logging.getLogger(__name__)
if typing.TYPE_CHECKING:
from networkx import DiGraph
from dvc.repo import Repo
from dvc.stage import Stage
from dvc.stage.loader import StageLoader
from dvc.types import OptStr
class StageInfo(NamedTuple):
stage: "Stage"
filter_info: Optional[PathInfo] = None
StageList = List["Stage"]
StageIter = Iterable["Stage"]
StageSet = Set["Stage"]
def _collect_with_deps(stages: StageList, graph: "DiGraph") -> StageSet:
res: StageSet = set()
for stage in stages:
res.update(collect_pipeline(stage, graph=graph))
return res
def _maybe_collect_from_dvc_yaml(
loader: "StageLoad", target, with_deps: bool, **load_kwargs,
) -> StageIter:
stages: StageList = []
if loader.tree.exists(PIPELINE_FILE):
with suppress(StageNotFound):
stages = loader.load_all(PIPELINE_FILE, target, **load_kwargs)
return _collect_with_deps(stages, loader.graph) if with_deps else stages
def _collect_specific_target(
loader: "StageLoad",
target: str,
with_deps: bool,
recursive: bool,
accept_group: bool,
) -> Tuple[StageIter, "OptStr", "OptStr"]:
# Optimization: do not collect the graph for a specific target
file, name = parse_target(target)
# if the target has a file, we can load directly from it.
if not file:
# but, if there's no file, parsing is ambiguous as it can be a
# stage name in `dvc.yaml` file or an output. We prioritize
# `dvc.yaml` stage name here. If it exists, then we move on.
# else, we assume it's a output name in the `collect_granular()` below
msg = "Checking if stage '%s' is in '%s'"
logger.debug(msg, target, PIPELINE_FILE)
if not (recursive and loader.tree.isdir(target)):
stages = _maybe_collect_from_dvc_yaml(
loader, target, with_deps, accept_group=accept_group,
)
if stages:
return stages, file, name
elif not with_deps and is_valid_filename(file):
stages = loader.load_all(file, name, accept_group=accept_group)
return stages, file, name
return [], file, name
class StageLoad:
def __init__(self, repo: "Repo") -> None:
self.repo = repo
def from_target(
self, target: str, accept_group: bool = False, glob: bool = False,
) -> StageList:
"""
Returns a list of stage from the provided target.
(see load method below for further details)
"""
path, name = parse_target(target, isa_glob=glob)
return self.load_all(
path=path, name=name, accept_group=accept_group, glob=glob,
)
def get_target(self, target: str) -> "Stage":
"""
Returns a stage from the provided target.
(see load_one method for further details)
"""
path, name = parse_target(target)
return self.load_one(path=path, name=name)
@staticmethod
def _get_filepath(path: str = None, name: str = None) -> str:
if path:
return path
path = PIPELINE_FILE
logger.debug("Assuming '%s' to be a stage inside '%s'", name, path)
return path
@staticmethod
def _get_group_keys(stages: "StageLoader", group: str) -> Iterable[str]:
from dvc.parsing import JOIN
for key in stages:
assert isinstance(key, str)
if key.startswith(f"{group}{JOIN}"):
yield key
def _get_keys(
self,
stages: "StageLoader",
name: str = None,
accept_group: bool = False,
glob: bool = False,
) -> Iterable[str]:
assert not (accept_group and glob)
if not name:
return stages.keys()
if accept_group and stages.is_foreach_generated(name):
return self._get_group_keys(stages, name)
elif glob:
return fnmatch.filter(stages.keys(), name)
return [name]
def load_all(
self,
path: str = None,
name: str = None,
accept_group: bool = False,
glob: bool = False,
) -> StageList:
"""Load a list of stages from a file.
Args:
path: if not provided, default `dvc.yaml` is assumed.
name: required for `dvc.yaml` files, ignored for `.dvc` files.
accept_group: if true, all of the the stages generated from `name`
foreach are returned.
glob: if true, `name` is considered as a glob, which is
used to filter list of stages from the given `path`.
"""
from dvc.stage.loader import SingleStageLoader, StageLoader
path = self._get_filepath(path, name)
dvcfile = Dvcfile(self.repo, path)
# `dvcfile.stages` is not cached
stages = dvcfile.stages # type: ignore
if isinstance(stages, SingleStageLoader):
stage = stages[name]
return [stage]
assert isinstance(stages, StageLoader)
keys = self._get_keys(stages, name, accept_group, glob)
return [stages[key] for key in keys]
def load_one(self, path: str = None, name: str = None) -> "Stage":
"""Load a single stage from a file.
Args:
path: if not provided, default `dvc.yaml` is assumed.
name: required for `dvc.yaml` files, ignored for `.dvc` files.
"""
path = self._get_filepath(path, name)
dvcfile = Dvcfile(self.repo, path)
stages = dvcfile.stages # type: ignore
return stages[name]
def load_file(self, path: str = None) -> StageList:
"""Load all of the stages from a file."""
return self.load_all(path)
def load_glob(self, path: str, expr: str = None):
"""Load stages from `path`, filtered with `expr` provided."""
return self.load_all(path, expr, glob=True)
@property
def tree(self):
return self.repo.tree
@property
def graph(self) -> "DiGraph":
return self.repo.graph
def collect(
self,
target: str = None,
with_deps: bool = False,
recursive: bool = False,
graph: "DiGraph" = None,
accept_group: bool = False,
glob: bool = False,
) -> StageIter:
"""Collect list of stages from the provided target.
Args:
target: if not provided, all of the stages in the graph are
returned.
Target can be:
- a stage name in the `dvc.yaml` file.
- a path to `dvc.yaml` or `.dvc` file.
- in case of a stage to a dvc.yaml file in a different
directory than current working directory, it can be a path
to dvc.yaml file, followed by a colon `:`, followed by stage
name (eg: `../dvc.yaml:build`).
- in case of `recursive`, it can be a path to a directory.
- in case of `accept_group`, it can be a group name of
`foreach` generated stage.
- in case of `glob`, it can be a wildcard pattern to match
stages. Example: `build*` for stages in `dvc.yaml` file, or
`../dvc.yaml:build*` for stages in dvc.yaml in a different
directory.
Note that, glob only applies for the stage name, not to the
file, so `**/dvc.yaml:build*` is not possible.
with_deps: if true, the stages including their dependencies are
returned.
recursive: if true and if `target` is a directory, all of the
stages inside that directory is returned.
graph: graph to use. Defaults to `repo.graph`.
accept_group: if true, all of the `foreach` generated stages of
the specified target is returned.
glob: Use `target` as a pattern to match stages in a file.
"""
if not target:
return list(graph) if graph else self.repo.stages
if recursive and self.repo.tree.isdir(target):
path = os.path.abspath(target)
return collect_inside_path(path, graph or self.graph)
stages = self.from_target(target, accept_group=accept_group, glob=glob)
if not with_deps:
return stages
return _collect_with_deps(stages, graph or self.graph)
def collect_granular(
self,
target: str = None,
with_deps: bool = False,
recursive: bool = False,
graph: "DiGraph" = None,
accept_group: bool = False,
) -> List[StageInfo]:
"""Collects a list of (stage, filter_info) from the given target.
Priority is in the order of following in case of ambiguity:
- .dvc file or .yaml file
- dir if recursive and directory exists
- stage_name
- output file
Args:
target: if not provided, all of the stages without any filters are
returned.
If `target` is a path to a dvc-tracked output,
a (stage, output_path_info) is returned.
Otherwise, the details above for `target` in `collect()`
applies.
(see `collect()` for other arguments)
"""
if not target:
return [StageInfo(stage) for stage in self.repo.stages]
stages, file, _ = _collect_specific_target(
self, target, with_deps, recursive, accept_group
)
if not stages:
if not (recursive and self.tree.isdir(target)):
try:
(out,) = self.repo.find_outs_by_path(target, strict=False)
filter_info = PathInfo(os.path.abspath(target))
return [StageInfo(out.stage, filter_info)]
except OutputNotFoundError:
pass
try:
stages = self.collect(
target,
with_deps,
recursive,
graph,
accept_group=accept_group,
)
except StageFileDoesNotExistError as exc:
# collect() might try to use `target` as a stage name
# and throw error that dvc.yaml does not exist, whereas it
# should say that both stage name and file does not exist.
if file and is_valid_filename(file):
raise
raise NoOutputOrStageError(target, exc.file) from exc
except StageNotFound as exc:
raise NoOutputOrStageError(target, exc.file) from exc
return [StageInfo(stage) for stage in stages]
+4 -2
View File
@@ -29,7 +29,9 @@ def _joint_status(pairs):
def _local_status(self, targets=None, with_deps=False, recursive=False):
targets = targets or [None]
pairs = cat(
self.collect_granular(t, with_deps=with_deps, recursive=recursive)
self.stage.collect_granular(
t, with_deps=with_deps, recursive=recursive
)
for t in targets
)
@@ -74,7 +76,7 @@ def _cloud_status(
{ "bar": "deleted" }
"""
import dvc.cache.base as cloud
import dvc.remote.base as cloud
used = self.used_cache(
targets,
+41
View File
@@ -0,0 +1,41 @@
from funcy import first
from pygtrie import Trie
from dvc.exceptions import OutputDuplicationError, OverlappingOutputPathsError
def build_outs_trie(stages):
outs = Trie()
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
return outs
+2 -2
View File
@@ -3,7 +3,7 @@ from . import locked
@locked
def update(self, targets, rev=None, recursive=False):
def update(self, targets=None, rev=None, recursive=False):
if not targets:
targets = [None]
@@ -12,7 +12,7 @@ def update(self, targets, rev=None, recursive=False):
stages = set()
for target in targets:
stages.update(self.collect(target, recursive=recursive))
stages.update(self.stage.collect(target, recursive=recursive))
for stage in stages:
stage.update(rev)
+44 -22
View File
@@ -1,7 +1,9 @@
from voluptuous import Any, Optional, Required, Schema
from dvc import dependency, output
from dvc.hash_info import HashInfo
from dvc.output import CHECKSUMS_SCHEMA, BaseOutput
from dvc.parsing import DO_KWD, FOREACH_KWD, SET_KWD, VARS_KWD
from dvc.stage.params import StageParams
STAGES = "stages"
@@ -15,9 +17,15 @@ SINGLE_STAGE_SCHEMA = {
StageParams.PARAM_FROZEN: bool,
StageParams.PARAM_META: object,
StageParams.PARAM_ALWAYS_CHANGED: bool,
StageParams.PARAM_DESC: str,
}
DATA_SCHEMA = {**CHECKSUMS_SCHEMA, Required("path"): str}
DATA_SCHEMA = {
**CHECKSUMS_SCHEMA,
Required("path"): str,
HashInfo.PARAM_SIZE: int,
HashInfo.PARAM_NFILES: int,
}
LOCK_FILE_STAGE_SCHEMA = {
Required(StageParams.PARAM_CMD): str,
StageParams.PARAM_DEPS: [DATA_SCHEMA],
@@ -27,7 +35,12 @@ LOCK_FILE_STAGE_SCHEMA = {
LOCKFILE_SCHEMA = {str: LOCK_FILE_STAGE_SCHEMA}
OUT_PSTAGE_DETAILED_SCHEMA = {
str: {BaseOutput.PARAM_CACHE: bool, BaseOutput.PARAM_PERSIST: bool}
str: {
BaseOutput.PARAM_CACHE: bool,
BaseOutput.PARAM_PERSIST: bool,
BaseOutput.PARAM_CHECKPOINT: bool,
BaseOutput.PARAM_DESC: str,
}
}
PLOT_PROPS = {
@@ -47,27 +60,36 @@ PLOT_PSTAGE_SCHEMA = {str: Any(PLOT_PROPS_SCHEMA, [PLOT_PROPS_SCHEMA])}
PARAM_PSTAGE_NON_DEFAULT_SCHEMA = {str: [str]}
SINGLE_PIPELINE_STAGE_SCHEMA = {
str: {
StageParams.PARAM_CMD: str,
Optional(StageParams.PARAM_WDIR): str,
Optional(StageParams.PARAM_DEPS): [str],
Optional(StageParams.PARAM_PARAMS): [
Any(str, PARAM_PSTAGE_NON_DEFAULT_SCHEMA)
],
Optional(StageParams.PARAM_FROZEN): bool,
Optional(StageParams.PARAM_META): object,
Optional(StageParams.PARAM_ALWAYS_CHANGED): bool,
Optional(StageParams.PARAM_OUTS): [
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
],
Optional(StageParams.PARAM_METRICS): [
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
],
Optional(StageParams.PARAM_PLOTS): [Any(str, PLOT_PSTAGE_SCHEMA)],
}
VARS_SCHEMA = [str, dict]
STAGE_DEFINITION = {
StageParams.PARAM_CMD: str,
Optional(SET_KWD): dict,
Optional(StageParams.PARAM_WDIR): str,
Optional(StageParams.PARAM_DEPS): [str],
Optional(StageParams.PARAM_PARAMS): [Any(str, dict)],
Optional(VARS_KWD): VARS_SCHEMA,
Optional(StageParams.PARAM_FROZEN): bool,
Optional(StageParams.PARAM_META): object,
Optional(StageParams.PARAM_DESC): str,
Optional(StageParams.PARAM_ALWAYS_CHANGED): bool,
Optional(StageParams.PARAM_OUTS): [Any(str, OUT_PSTAGE_DETAILED_SCHEMA)],
Optional(StageParams.PARAM_METRICS): [
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
],
Optional(StageParams.PARAM_PLOTS): [Any(str, PLOT_PSTAGE_SCHEMA)],
}
FOREACH_IN = {
Optional(SET_KWD): dict,
Required(FOREACH_KWD): Any(dict, list, str),
Required(DO_KWD): STAGE_DEFINITION,
}
SINGLE_PIPELINE_STAGE_SCHEMA = {str: Any(STAGE_DEFINITION, FOREACH_IN)}
MULTI_STAGE_SCHEMA = {
STAGES: SINGLE_PIPELINE_STAGE_SCHEMA,
VARS_KWD: VARS_SCHEMA,
}
MULTI_STAGE_SCHEMA = {STAGES: SINGLE_PIPELINE_STAGE_SCHEMA}
COMPILED_SINGLE_STAGE_SCHEMA = Schema(SINGLE_STAGE_SCHEMA)
COMPILED_MULTI_STAGE_SCHEMA = Schema(MULTI_STAGE_SCHEMA)
+1
View File
@@ -1,6 +1,7 @@
class Schemes:
SSH = "ssh"
HDFS = "hdfs"
WEBHDFS = "webhdfs"
S3 = "s3"
AZURE = "azure"
HTTP = "http"
+5 -1
View File
@@ -41,7 +41,7 @@ class Base:
self._root_dir = os.path.realpath(root_dir)
@property
def root_dir(self):
def root_dir(self) -> str:
return self._root_dir
def __repr__(self):
@@ -66,6 +66,10 @@ class Base:
"""
return True
def is_ignored(self, path): # pylint: disable=unused-argument
"""Returns whether or not path is ignored by SCM."""
return False
def ignore(self, path): # pylint: disable=unused-argument
"""Makes SCM ignore a specified path."""
+117 -18
View File
@@ -3,9 +3,11 @@
import logging
import os
import shlex
from functools import partial
from contextlib import contextmanager
from functools import partial, partialmethod
from typing import Optional
from funcy import cached_property
from funcy import cached_property, first
from pathspec.patterns import GitWildMatchPattern
from dvc.exceptions import GitHookAlreadyExistsError
@@ -21,6 +23,11 @@ from dvc.utils import fix_env, is_binary, relpath
from dvc.utils.fs import path_isin
from dvc.utils.serialize import modify_yaml
from .backend.base import NoGitBackendError
from .backend.dulwich import DulwichBackend
from .backend.gitpython import GitPythonBackend
from .stash import Stash
logger = logging.getLogger(__name__)
@@ -61,6 +68,8 @@ class Git(Base):
GITIGNORE = ".gitignore"
GIT_DIR = ".git"
DEFAULT_BACKENDS = (DulwichBackend, GitPythonBackend)
LOCAL_BRANCH_PREFIX = "refs/heads/"
def __init__(self, root_dir=os.curdir, search_parent_directories=True):
"""Git class constructor.
@@ -88,10 +97,16 @@ class Git(Base):
self.ignored_paths = []
self.files_to_track = set()
self.backends = [backend(self) for backend in self.DEFAULT_BACKENDS]
@property
def root_dir(self):
def root_dir(self) -> str:
return self.repo.working_tree_dir
@cached_property
def stash(self):
return Stash(self)
@staticmethod
def clone(url, to_path, rev=None, shallow_branch=None):
import git
@@ -182,19 +197,10 @@ class Git(Base):
return entry, gitignore
def _ignored(self, path):
from git.exc import GitCommandError
try:
self.repo.git.check_ignore(path)
return True
except GitCommandError:
return False
def ignore(self, path):
entry, gitignore = self._get_gitignore(path)
if self._ignored(path):
if self.is_ignored(path):
return
msg = "Adding '{}' to '{}'.".format(relpath(path), relpath(gitignore))
@@ -257,11 +263,11 @@ class Git(Base):
def commit(self, msg):
self.repo.index.commit(msg)
def checkout(self, branch, create_new=False):
def checkout(self, branch, create_new=False, **kwargs):
if create_new:
self.repo.git.checkout("HEAD", b=branch)
self.repo.git.checkout("HEAD", b=branch, **kwargs)
else:
self.repo.git.checkout(branch)
self.repo.git.checkout(branch, **kwargs)
def pull(self, **kwargs):
infos = self.repo.remote().pull(**kwargs)
@@ -288,8 +294,8 @@ class Git(Base):
def is_tracked(self, path):
return bool(self.repo.git.ls_files(path))
def is_dirty(self):
return self.repo.is_dirty()
def is_dirty(self, **kwargs):
return self.repo.is_dirty(**kwargs)
def active_branch(self):
return self.repo.active_branch.name
@@ -448,6 +454,8 @@ class Git(Base):
return False
def close(self):
for backend in self.backends:
backend.close()
self.repo.close()
@cached_property
@@ -469,3 +477,94 @@ class Git(Base):
@property
def no_commits(self):
return not self.list_all_commits()
def branch_revs(self, branch: str, end_rev: Optional[str] = None):
"""Iterate over revisions in a given branch (from newest to oldest).
If end_rev is set, iterator will stop when the specified revision is
reached.
"""
commit = self.resolve_commit(branch)
while commit is not None:
yield commit.hexsha
commit = first(commit.parents)
if commit and commit.hexsha == end_rev:
return
def resolve_commit(self, rev):
"""Return Commit object for the specified revision."""
from git.exc import BadName, GitCommandError
from git.objects.tag import TagObject
try:
commit = self.repo.rev_parse(rev)
except (BadName, GitCommandError):
commit = None
if isinstance(commit, TagObject):
commit = commit.object
return commit
def _backend_func(self, name, *args, **kwargs):
for backend in self.backends:
try:
func = getattr(backend, name)
return func(*args, **kwargs)
except NotImplementedError:
pass
raise NoGitBackendError(name)
is_ignored = partialmethod(_backend_func, "is_ignored")
set_ref = partialmethod(_backend_func, "set_ref")
get_ref = partialmethod(_backend_func, "get_ref")
remove_ref = partialmethod(_backend_func, "remove_ref")
iter_refs = partialmethod(_backend_func, "iter_refs")
get_refs_containing = partialmethod(_backend_func, "get_refs_containing")
push_refspec = partialmethod(_backend_func, "push_refspec")
fetch_refspecs = partialmethod(_backend_func, "fetch_refspecs")
_stash_iter = partialmethod(_backend_func, "_stash_iter")
_stash_push = partialmethod(_backend_func, "_stash_push")
_stash_apply = partialmethod(_backend_func, "_stash_apply")
reflog_delete = partialmethod(_backend_func, "reflog_delete")
describe = partialmethod(_backend_func, "describe")
diff = partialmethod(_backend_func, "diff")
@contextmanager
def detach_head(self, rev: Optional[str] = None):
"""Context manager for performing detached HEAD SCM operations.
Detaches and restores HEAD similar to interactive git rebase.
Restore is equivalent to 'reset --soft', meaning the caller is
is responsible for preserving & restoring working tree state
(i.e. via stash) when applicable.
Yields revision of detached head.
"""
if not rev:
rev = "HEAD"
orig_head = self.get_ref("HEAD", follow=False)
logger.debug("Detaching HEAD at '%s'", rev)
self.checkout(rev, detach=True)
try:
yield self.get_ref("HEAD")
finally:
prefix = self.LOCAL_BRANCH_PREFIX
if orig_head.startswith(prefix):
orig_head = orig_head[len(prefix) :]
logger.debug("Restore HEAD to '%s'", orig_head)
self.checkout(orig_head)
@contextmanager
def stash_workspace(self, **kwargs):
"""Stash and restore any workspace changes.
Yields revision of the stash commit. Yields None if there were no
changes to stash.
"""
logger.debug("Stashing workspace")
rev = self.stash.push(**kwargs)
try:
yield rev
finally:
if rev:
logger.debug("Restoring stashed workspace")
self.stash.pop()

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