Compare commits

...
153 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 3aa9f51e36 output: suppress cloud.pull errors on cache collection
Fixes https://github.com/iterative/studio/issues/5261
2023-03-09 22:44:32 +02:00
dependabot[bot] 1cfdc22549 build(deps-dev): bump pylint from 2.16.4 to 2.17.0 (#9141)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.16.4 to 2.17.0.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.16.4...v2.17.0)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-09 16:23:47 +05:45
skshetry 2b889baa5e plots: avoid printing anything on --json output (#9144) 2023-03-09 16:10:46 +05:45
Peter Rowlands 8589aa4001 tests: mark test_queue_remove_done xfail 2023-03-09 16:25:09 +09:00
Peter Rowlands 8cbc4c43f1 tests: use session-scoped exp celery fixtures when possible 2023-03-09 16:25:09 +09:00
Peter Rowlands 417c327143 exp: add session-scoped celery test fixtures 2023-03-09 16:25:09 +09:00
Peter Rowlands 204eebaf15 tests: use brancher.switch 2023-03-09 15:02:26 +09:00
Peter Rowlands 3a6fd75d98 repo: use brancher.switch 2023-03-09 15:02:26 +09:00
Peter Rowlands 351b952d5f brancher: add brancher.switch helper 2023-03-09 15:02:26 +09:00
dberenbaum fe7db32311 studio: handle revision errors when posting 2023-03-08 22:19:32 +01:00
Ivan Shcheklein 0bd34c8977 fix(push): allow no default remote 2023-03-08 21:26:47 +02:00
skshetry 48a63ca588 try to improve test speed (#9136)
* tests: fix logic to gc.collect() on Windows + 3.11

* dvc.cli: cache get_main_parser()

* tests: isolate environment on a session-level

* bring back gc.collect()
2023-03-08 22:11:42 +05:45
Gao 4459d6ac57 Add back an dvc ignore test
Original from
https://github.com/iterative/dvc/pull/8767#discussion_r1070778749
2023-03-08 12:03:07 +02:00
skshetry 08a50ee36e tests: error on ResourceWarning (#9123)
* tests: error on ResourceWarning

* run-cache: avoid leaking temp files on Windows

* set returncode to popen to avoid ResourceWarning on gc.collect()
2023-03-08 15:36:10 +05:45
David de la Iglesia Castro f1ea64c4ef studio: Send metrics on done event. (#9027) 2023-03-07 19:47:22 +01:00
dependabot[bot] 7ede45af2d build(deps-dev): bump mypy from 1.0.1 to 1.1.1 (#9126)
Bumps [mypy](https://github.com/python/mypy) from 1.0.1 to 1.1.1.
- [Release notes](https://github.com/python/mypy/releases)
- [Commits](https://github.com/python/mypy/compare/v1.0.1...v1.1.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-07 23:13:54 +05:45
skshetry 51db1765ee migrate from appdirs to platformdirs (#9131)
appdirs has been deprecated/unmaintained, platformdirs is now recommended.
Closes #9130.
2023-03-07 23:13:28 +05:45
Peter Rowlands 9101fce761 proc: remove dvc.proc 2023-03-07 18:17:01 +09:00
Peter Rowlands 3f842a205f exp show: fix cached running checkpoint state bug 2023-03-07 16:55:23 +09:00
dependabot[bot] 77e900fed2 build(deps-dev): bump pylint from 2.16.3 to 2.16.4 (#9127)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.16.3 to 2.16.4.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.16.3...v2.16.4)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-07 10:17:04 +05:45
David de la Iglesia Castro dde456c923 plots: Fix match_defs_renderers when plots are defined in nt format.
`plots.show` returned `definitions` in native `nt` format but sources in `posixpath` format so the matching was failing.

Closes #8689

- Add `test_show_plots_defined_with_native_os_path`.
2023-03-06 19:16:29 +01:00
David de la Iglesia Castro cb9d3529af plots: Remove --from-config 2023-03-06 19:16:29 +01:00
David de la Iglesia Castro 876c10fcf5 plots: Remove unnecessary OrderedDict usage in load_sv. 2023-03-06 19:16:29 +01:00
skshetry 93eea0cd42 deps: bump scmrepo to 0.1.15; close subrepo.dvc on tests teardown (#9122) 2023-03-06 20:21:42 +05:45
skshetry 25fd274090 increase timeout for tests to 40 minutes 2023-03-06 20:04:26 +05:45
skshetry ddfa607437 tests: don't leak files/descriptors when tests finish (#9119)
close open files/descriptors when tests finish
2023-03-06 18:06:37 +05:45
skshetry c5a3566e49 deps (tests): require pytest-xdist>=3.2, use -n=logical workers and workstealing scheduler (#9116) 2023-03-06 15:54:45 +05:45
skshetry e7d4f5b24e tests: filter warnings (#9115) 2023-03-06 15:41:45 +05:45
Peter Rowlands 1d2892efac exp show: don't cache collection errors 2023-03-06 15:25:27 +09:00
dependabot[bot] 83835afa69 build(deps-dev): bump pylint from 2.16.2 to 2.16.3 (#9113)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.16.2 to 2.16.3.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.16.2...v2.16.3)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-06 10:33:02 +05:45
skshetry 4c5d052d8c update ruff to v0.0.254 (#9110) 2023-03-04 17:26:59 +05:45
David de la Iglesia Castro e4d514c990 scm: Remove unused GitMergeError.
Not used after https://github.com/iterative/dvc/pull/9096
2023-03-03 18:09:57 +01:00
David de la Iglesia Castro baeecd1972 deps: Bump scmrepo>=0.1.14 2023-03-03 15:42:41 +01:00
David de la Iglesia Castro dbd098506c deps: Drop dvclive.
See https://github.com/iterative/dvclive/pull/481
2023-03-03 14:45:48 +01:00
Ruslan Kuprieiev 4779c248f8 deps: bump dvc-data to >=0.42.1 2023-03-02 17:53:07 +02:00
Peter Rowlands 2cf3a12e46 tests: unmark exp celery tests as xfail 2023-03-02 15:44:34 +09:00
Peter Rowlands 78fb5275e5 exp: add dvc exp clean command 2023-03-02 12:35:54 +09:00
Peter Rowlands ccbe2de89e exp: auto cleanup celery on queue worker exit 2023-03-02 12:35:54 +09:00
Peter Rowlands 54e39e4abd deps: bump dvc-task to >=0.2.0 2023-03-02 12:35:54 +09:00
Ruslan Kuprieiev 59a4c4f67c remote: set hash_name as md5
Fixes #7977
2023-03-01 13:26:24 +02:00
Peter Rowlands 1a7ae16f69 exp: use stash-apply instead of merge 2023-03-01 19:11:12 +09:00
Peter Rowlands 286f4fda3e repo: call Repo.close() on Repo.__exit__() 2023-03-01 17:49:27 +09:00
Peter Rowlands d2c63bffcb exp: ensure repo instance is closed after executor.reproduce 2023-03-01 17:49:27 +09:00
Peter Rowlands 2b41305c51 exp: fix queued run file descriptor leak 2023-03-01 17:49:27 +09:00
Ruslan Kuprieiev 546becddaf deps: bump dvc-gs to 2.22.0 2023-03-01 08:36:33 +02:00
David de la Iglesia Castro c6015a05a3 exp save: Don't relink during commit
Same change as in https://github.com/iterative/dvc/pull/9076 but for `exp save`
2023-02-28 22:29:57 +02:00
Ruslan Kuprieiev c0754ee5b3 deps: bump dvc-data to 0.42.0 2023-02-28 14:48:34 +02:00
skshetry 4db2e89938 build: use macos-11 to build package (#9091)
macos-10.15 image has been deprecated for a while, see https://github.blog/changelog/2022-07-20-github-actions-the-macos-10-15-actions-runner-image-is-being-deprecated-and-will-be-removed-by-8-30-22/ and https://github.com/actions/runner-images/issues/5583.

setup-ruby removed the support for these images yesterday: https://github.com/ruby/setup-ruby/pull/467.

which fails our package builds. Now using macos-11 to build packages.
2023-02-28 17:33:05 +05:45
skshetry 4a6f3ed64a dvc.testing: fix lockfile in docker_services fixture (#9088)
dvc.testing.docker_services: rollback to previous state

where we were not tearing down the containers
2023-02-28 16:54:33 +05:45
David de la Iglesia Castro 9f0c4350bd deps: Bump scmrepo>=0.1.13.
Closes #9080
2023-02-28 17:46:09 +09:00
Ruslan Kuprieiev a91528c3c9 Revert "testing: fixtures: fix docker regression"
This reverts commit b80d84e689.
2023-02-27 15:02:22 +02:00
Ruslan Kuprieiev b80d84e689 testing: fixtures: fix docker regression 2023-02-27 14:17:07 +02:00
skshetry 2a2c02c184 remove typing.cast (#9082) 2023-02-26 13:15:54 +05:45
Dave Berenbaum 35b648bbcf plots: shorten names in the legend (#8900)
* plots: shorten field names

* plots: handle missing field names

* plots: make compatible with py3.8

* plots: fix for missing data

* plots: add test for field shortening

* plots: strip separators regardless of os

* plots: don't shorten field name

* plots: drop test for shortening field name
2023-02-24 17:26:16 +01:00
Peter Rowlands 96ac321dd5 repo: use local root when determining state/index db dirname 2023-02-24 16:33:42 +09:00
Peter Rowlands 50fd8ca15d exp show: cache git refs per show() call 2023-02-24 16:33:26 +09:00
David de la Iglesia Castro 184cc35cf0 pull: Add allow_missing flag.
Allows `pull` to succeed even if some files or directories are missing.

Closes #4746
2023-02-24 09:11:50 +02:00
Ruslan Kuprieiev 81da91a973 exp: run: don't relink during commit
Part of #8809
2023-02-24 08:09:35 +02:00
Peter Rowlands ad5b91e51a exp show: cache collected git revs 2023-02-24 14:18:58 +09:00
Peter Rowlands 391cf80e7b exp: support caching collected exp data 2023-02-24 14:18:58 +09:00
Peter Rowlands 8d13486048 exp: add SerializableExp dataclasses 2023-02-24 14:18:58 +09:00
Ruslan Kuprieiev 37761a2627 data: status: get rid of --not-in-remote 2023-02-21 12:32:40 +02:00
Ruslan Kuprieiev 968a8fde99 data: status: introduce --remote-refresh 2023-02-21 12:32:40 +02:00
David de la Iglesia Castro 3043b6588f exp save: Use pathspec to handle include_untracked.
Closes #9061
2023-02-21 09:43:25 +01:00
47208533a2 Add metrics_show to dvc.api (#9042)
* refactor: add metrics_show, rename module to show

* tests: add functional tests for metrics_show

* api.show: return empty dict for no params/metrics

* test_show: Return relpaths in `metrics_repo`.

---------

Co-authored-by: codamuse <[email protected]>
Co-authored-by: David de la Iglesia Castro <[email protected]>
2023-02-20 21:53:38 +00:00
Ruslan Kuprieiev 09ccd9c7e4 repo: data_index: store in .dvc/tmp/index/data 2023-02-20 21:57:27 +02:00
Ruslan Kuprieiev cd9dd0a312 index: use has_node instead of ls
Current code doesn't work for prefixes longer than one, which makes index cache
effectively not work and it should've used has_node from the very start anyway.
2023-02-20 17:45:15 +02:00
Ruslan Kuprieiev 6fbbbeccc5 index: store data index under tree prefix
Tidying it up, so root of the index will look like this:

```
tree/
remote/
```

This hasn't been released yet, so we are not breaking anything.
2023-02-20 13:33:22 +02:00
Ruslan Kuprieiev c34cbf63af data: status: introduce --not-in-remote
Also supports cloud versioning.

Fixes #8761
2023-02-20 12:25:17 +02:00
Ruslan Kuprieiev b76aad8d56 data: status: rename with_missing to not_in_cache 2023-02-20 12:25:17 +02:00
Ruslan Kuprieiev 68bf095eae remote: rename index prefix from remotes to remote 2023-02-20 12:24:46 +02:00
Ruslan Kuprieiev ea6e01d0e0 dvcfs: implement get_file
Currently we use `open`, which is much slower. For example,

```
$ dvc get dvc-bench data/small/dataset
```

goes down from 110sec to 50sec.

Discovered as a part of https://github.com/iterative/dvc-bench/issues/419
2023-02-20 11:10:13 +02:00
dependabot[bot]andSaugat Pachhai 19038b32bd build(deps-dev): unpin pytest-docker
* fix docker fixtures and unpin pytest-docker
* git_server: mount volume to different /custom-cont-init.d

---------
Co-authored-by: Saugat Pachhai (सौगात) <[email protected]>
2023-02-20 12:57:31 +05:45
Peter Rowlands 648edd71eb exp: move apply tests into their own module 2023-02-20 14:36:38 +09:00
Peter Rowlands 0c95cde712 exp: remove unused ApplyConflictError 2023-02-20 14:36:38 +09:00
Peter Rowlands e218431f64 apply: add --no-force deprecation message 2023-02-20 14:36:38 +09:00
Peter Rowlands d3182e0dd9 exp apply: drop --no-force CLI option 2023-02-20 14:36:38 +09:00
Peter Rowlands e040e875dc exp apply: simplify apply behavior to use checkout instead of merge 2023-02-20 14:36:38 +09:00
Peter Rowlands 303da84b18 exp: add ApplyStash implementation 2023-02-20 14:36:38 +09:00
Peter Rowlands 03bfdf2663 deps: use ranges for dvc sub-packages 2023-02-20 14:35:08 +09:00
skshetry 8edaef0103 deps: drop importlib-resources and typing-extensions (#9055)
deps: remove importlib-resources and typing-extensions
2023-02-19 11:36:48 +05:45
skshetry fd8a8c2729 pyproject: format using pyproject-fmt (#9053)
I haven't added pyproject-fmt to pre-commit hook because of two reasons:
1. There is a bug that keeps adding empty lines after `[[tool.mypy.overrides]]`.
2. The list gets exploded to multiple lines. However, this is a minor issue.
2023-02-18 23:07:57 +05:45
dependabot[bot] 30337bf0ba build(deps): bump pyinstaller from 5.7.0 to 5.8.0 (#9013)
Bumps [pyinstaller](https://github.com/pyinstaller/pyinstaller) from 5.7.0 to 5.8.0.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v5.7.0...v5.8.0)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-18 22:53:17 +05:45
skshetry 5426ca6f79 deps (tests): unpin test deps (#9052) 2023-02-18 22:41:50 +05:45
skshetry a89c25700f pyproject.toml: cleanup metadata (#9051) 2023-02-18 17:40:36 +05:45
dependabot[bot] 0e0bb8ad76 build(deps-dev): bump mypy from 0.991 to 1.0.1 (#9049)Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [mypy](https://github.com/python/mypy) from 0.991 to 1.0.1.
- [Release notes](https://github.com/python/mypy/releases)
- [Commits](https://github.com/python/mypy/compare/v0.991...v1.0.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-18 17:03:41 +05:45
skshetry 1de37556d5 bump ruff to v0.0.247 (#9050)
* bump ruff to v0.0.247

* ruff: enable pep8-naming check

rename some camelCased APIs, especially in logger
rename G variables to graph
ignore some variable namings and help messages in dvc.commands
2023-02-18 16:50:35 +05:45
Ruslan Kuprieiev 098685b8f3 deps: bump dvc-data to 0.40.4
Improves index view related performance (e.g. `dvc diff`).
2023-02-18 11:35:01 +02:00
Ruslan Kuprieiev 8f549c8a8d config: enable data index caching by default 2023-02-18 08:00:58 +02:00
Ruslan Kuprieiev e56d97bb28 gha: user ubuntu 20.04 for deb packages
18.04 will reach EOL in april and will be dropped by gha.

18.04 also comes with an old sqlite version which forces us to use obsolete
syntax.
2023-02-18 08:00:58 +02:00
Ruslan Kuprieiev 07b72c20e9 repo: move data_index to .dvc/tmp/index 2023-02-17 21:43:57 +02:00
Ruslan Kuprieiev 592f251481 repo: use tmp_dir if available 2023-02-17 21:43:57 +02:00
Ruslan Kuprieiev 890c0020ed Revert "repo: use tmp_dir if available"
This reverts commit 7ccf35b47e.
2023-02-17 21:25:46 +02:00
Ruslan Kuprieiev 7ce0b1cba2 Revert "repo: move data_index to .dvc/tmp/index"
This reverts commit d68599a5f7.
2023-02-17 21:25:46 +02:00
Ruslan Kuprieiev b8a9c37af5 Revert "config: enable data index caching by default"
This reverts commit 1af63acae5.
2023-02-17 21:25:46 +02:00
Ruslan Kuprieiev 1af63acae5 config: enable data index caching by default 2023-02-17 19:55:25 +02:00
Ruslan Kuprieiev d68599a5f7 repo: move data_index to .dvc/tmp/index 2023-02-17 19:55:25 +02:00
Ruslan Kuprieiev 7ccf35b47e repo: use tmp_dir if available 2023-02-17 19:55:25 +02:00
Ruslan Kuprieiev 4f49023a08 config: don't set default cache dir
Setting default values is very untypical for all other applications and the
info config has is not enough to make a decision on the default cache dir,
especially when dealing with gitfs.

Fixes #8705
2023-02-17 07:57:39 +02:00
skshetry d7ea7c358d Revert "scm: use dulwich backend when fetching exps during clone/pull" (#9041)
Revert "scm: use dulwich backend when fetching exps during clone/pull (#9023)"

This reverts commit ed1ab7fa75.
2023-02-16 18:07:45 +05:45
dependabot[bot] 3f0da8f6cc build(deps-dev): bump scmrepo from 0.1.10 to 0.1.11 (#9039)Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [scmrepo](https://github.com/iterative/scmrepo) from 0.1.10 to 0.1.11.
- [Release notes](https://github.com/iterative/scmrepo/releases)
- [Commits](https://github.com/iterative/scmrepo/compare/0.1.10...0.1.11)

---
updated-dependencies:
- dependency-name: scmrepo
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-16 18:03:13 +05:45
David de la Iglesia Castro b7b7eff4a3 tests: Fix test_deleted.
Closes #6297
#9017 actually fixed this but the test was incorrect (something wrong with caplog).
2023-02-16 11:43:09 +01:00
skshetry ddeb32ed33 ui: log footer support message to standard error (#9034) 2023-02-16 11:31:31 +05:45
Ruslan Kuprieiev c7be921f44 cachemgr: don't use tmp_dir
We used to use it in gdrive before https://github.com/iterative/PyDrive2/pull/211
2023-02-16 06:43:20 +02:00
Ruslan Kuprieiev c95c3f0f7a deps: limit types-tqdm
See https://github.com/iterative/dvc/pull/9036
2023-02-16 06:43:20 +02:00
skshetry be824ede0d render: avoid deepcopying (#9033)
* render: avoid deepcopying

* remove unnecessary line
2023-02-15 16:25:45 +05:45
Ruslan Kuprieiev cbe46cccd8 data: status: fix typo
Leftover from #9031
2023-02-15 07:18:19 +02:00
Ruslan Kuprieiev 49b5157abe diff: use index 2023-02-15 06:40:02 +02:00
Ruslan Kuprieiev 6f87c14bf9 data: status: bring back ui.status 2023-02-15 05:57:50 +02:00
Ruslan Kuprieiev 0f80d16ba7 data: status: fix typing 2023-02-15 05:57:50 +02:00
Ruslan Kuprieiev 4b23b4f8ae repo: cache index for cloud versioning
This allows us to reuse cloud versioning index that we've built in other
operations like `data status`.

Note, this only works with `dvc config feature.data_index_cache true`
right now.

Also note that index is not populated for regular remotes, it will come
in a separate PR as there are some http-related issues to figure out.

Related #8962
2023-02-15 04:53:48 +02:00
Ruslan Kuprieiev 446871d84f index: build_data_index: use dvcignore
Fixes #9008
2023-02-14 21:33:43 +02:00
dependabot[bot] 99bbe15980 build(deps-dev): bump pylint from 2.16.1 to 2.16.2 (#9020)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.16.1 to 2.16.2.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.16.1...v2.16.2)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-14 22:35:59 +05:45
David de la Iglesia Castro 320771551b exp: Preserve staged changes after experiments.
- Detach head after stashing workspace.
- Use reinstate_index.
2023-02-14 16:59:10 +01:00
David de la Iglesia Castro 1676d1f344 Add test_stash_exp. 2023-02-14 16:59:10 +01:00
David de la Iglesia Castro c8ae2895f0 deps: bump scmrepo==0.1.10
https://github.com/iterative/scmrepo/releases/tag/0.1.10
2023-02-14 16:59:10 +01:00
skshetry ed1ab7fa75 scm: use dulwich backend when fetching exps during clone/pull (#9023)
refspecs uses pygit2 backend by default, which does not support git credential
helpers. This PR forces to use dulwich backend during clone/pull operations.
2023-02-14 19:18:40 +05:45
Karan Jakhar 73497dae9e Handle filenames which has ":" (#9000)
* Fixes #8773 issue

* added test for #8773 issue

* skip test for windows
2023-02-14 11:21:32 +00:00
skshetry eff775c760 brancher: add trace when switching filesystem (#9022)
brancher: add trace info when switching filesystem
2023-02-14 15:27:18 +05:45
Ruslan Kuprieiev e73d662686 repo: introduce data_index
Extracting from `index.data` as a "root" data index that contains indexes
for different revisions/workspaces.

This is a setup for putting cloud versioning remote indexes and also replacing
old state.
2023-02-13 06:31:44 +02:00
Ruslan Kuprieiev 86a6fb104a index: experimental persistent data index
Related https://github.com/iterative/dvc-data/issues/208
2023-02-13 02:57:30 +02:00
Ruslan Kuprieiev 3b097dcccd deps: bump dvc-data to 0.40.1 2023-02-12 09:12:08 +02:00
David de la Iglesia Castro e910dac0dc Update pyproject.toml 2023-02-10 10:59:32 +01:00
David de la Iglesia Castro 17a71d6011 plots: Rename template_path to html_template. 2023-02-10 10:59:32 +01:00
dependabot[bot] 5a6ccc8027 build(deps-dev): bump dvc-render from 0.1.1 to 0.1.2
Bumps [dvc-render](https://github.com/iterative/dvc-render) from 0.1.1 to 0.1.2.
- [Release notes](https://github.com/iterative/dvc-render/releases)
- [Commits](https://github.com/iterative/dvc-render/compare/0.1.1...0.1.2)

---
updated-dependencies:
- dependency-name: dvc-render
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2023-02-10 10:59:32 +01:00
pre-commit-ci[bot] 6dccfcb3f9 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-02-10 04:47:57 +02:00
karajan1001 2b0c365f35 Fix some mypy error. 2023-02-10 04:47:57 +02:00
dberenbaum 611e2f4311 update rich 2023-02-09 23:18:50 +02:00
dberenbaum 561a375b48 update: drop worktree from cli 2023-02-09 23:03:50 +02:00
skshetry e51345e276 get/import: lazy import dvc.scm; reduces dvc --help by ~20% (#8996) 2023-02-09 15:04:55 +05:45
skshetry 00e61e18ae logging: do not disable other loggers (#8995)
Closes https://github.com/iterative/dvc/issues/8631
2023-02-09 15:04:29 +05:45
skshetry 5490fc00d1 logger: check isatty stream to print colored logs or not instead of depending on colorama (#8993
logger: check isatty stream to print colored logs or not
2023-02-09 13:50:38 +05:45
skshetry f1043676da [WIP] logging: change from dictConfig to equivalent code for creating logge… (#8985
logging: change from dictConfig to equivalent code for creating loggers/handlers/formatters

dictConfig clears all handlers, and potentially disables existing loggers (we disabled this functionality
though). There's much more code than necessary in dictConfig than what we need.

I have tried to keep the setup equivalent to what we had in the config, so I have not tried to cleanup
the code.
2023-02-09 09:34:14 +05:45
Ruslan Kuprieiev f7ebcbb974 deps: bump dvc-data to 0.39.3 2023-02-09 05:44:50 +02:00
dependabot[bot] c637b963e0 build(deps-dev): bump dvc-azure from 2.21.0 to 2.21.1 (#8991)
Bumps [dvc-azure](https://github.com/iterative/dvc-azure) from 2.21.0 to 2.21.1.
- [Release notes](https://github.com/iterative/dvc-azure/releases)
- [Commits](https://github.com/iterative/dvc-azure/compare/2.21.0...2.21.1)

---
updated-dependencies:
- dependency-name: dvc-azure
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-09 08:31:13 +05:45
Ruslan Kuprieiev 9156bb80db output: rename odb -> cache 2023-02-09 00:44:18 +02:00
Ruslan Kuprieiev 58295ca0ab repo: rename odb -> cache
We used to have it named `cache` before, but these days it is clear that
you can have three odbs: just odb (e.g. in-memory), cache (typically local)
or remote (typically cloud). Fixing naming here before proceeding with
introducing `out.odb` and `out.cache`.

In preparation for migration to https://github.com/iterative/dvc-data/pull/304
2023-02-08 23:05:45 +02:00
skshetry 4176007493 push: check if versioning is supported or not during checkout on cloud versioning (#8949)Fixes https://github.com/iterative/dvc/issues/8824
* push: check if versioning is supported or not during checkout on cloud versioning

* bump dvc-data to 0.38.4
2023-02-08 17:05:27 +05:45
dependabot[bot]andGao 9d7883f529 build(deps-dev): bump pytest-xdist from 3.1.0 to 3.2.0 (#8981)
Bumps [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) from 3.1.0 to 3.2.0.
- [Release notes](https://github.com/pytest-dev/pytest-xdist/releases)
- [Changelog](https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-xdist/compare/v3.1.0...v3.2.0)

---
updated-dependencies:
- dependency-name: pytest-xdist
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gao <[email protected]>
2023-02-08 07:31:21 +00:00
Peter Rowlands 93c556acdd index: fix index entries for partial import stages 2023-02-08 15:34:15 +09:00
Peter Rowlands 676b1e47e9 import-url: update stage md5 after unpartialing imports 2023-02-08 15:34:15 +09:00
Peter Rowlands 0b141ac08a testing: test cloud versioned import-url --no-download 2023-02-08 15:34:15 +09:00
dependabot[bot] b4db1e06a1 build(deps-dev): bump dvc-render from 0.1.0 to 0.1.1 (#8982)
Bumps [dvc-render](https://github.com/iterative/dvc-render) from 0.1.0 to 0.1.1.
- [Release notes](https://github.com/iterative/dvc-render/releases)
- [Commits](https://github.com/iterative/dvc-render/compare/0.1.0...0.1.1)

---
updated-dependencies:
- dependency-name: dvc-render
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-08 09:11:42 +05:45
dependabot[bot] 0d0fff6ddc build(deps-dev): bump dvc-data from 0.38.1 to 0.38.3 (#8983)
Bumps [dvc-data](https://github.com/iterative/dvc-data) from 0.38.1 to 0.38.3.
- [Release notes](https://github.com/iterative/dvc-data/releases)
- [Commits](https://github.com/iterative/dvc-data/compare/0.38.1...0.38.3)

---
updated-dependencies:
- dependency-name: dvc-data
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-08 09:11:28 +05:45
Hagai Kariti 6c8c26f741 Support setting a exp name prefix in hydra sweeps 2023-02-07 18:49:23 +01:00
Peter Rowlands f68a12558a testing: mark 00 prefix tests xfail on fs without find() 2023-02-07 15:17:29 +09:00
Ruslan Kuprieiev a8a5b29fb7 deps: bump dvc-data to 0.38.1 (#8976) 2023-02-07 06:58:15 +02:00
skshetry 037e2e54f0 serialization: reorder files section metadata (#8950
serialization: reorder files section
2023-02-07 10:05:36 +05:45
skshetry ae80889477 logging: defer calling colorama.init() in jupyter, otherwise initialize immediately (#8969)Closes https://github.com/iterative/dvc/issues/8957
logging: init colorama before logging if outside jupyter env

Otherwise, tty detection does not work as `colorama.init()` mutates
sys.stdout/sys.stderr, and after `dictConfig()`, the handlers will hold
on to the stream.
2023-02-07 10:05:05 +05:45
Gao e1acab5113 Use pygit2 backend for fetch_refspecs (#8907)
fix: #8762
1. partly solve #8787 and #8477
2. Improve the performance
3. Bump `scmrepo` into 0.1.9
2023-02-06 16:07:00 +02:00
d84196db62 studio: Move post_live_metrics from queue to executor (#8955)
* studio: Move `post_live_metrics` from `queue/workspace` to `executor/base`.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update dvc/repo/experiments/utils.py

Co-authored-by: skshetry <[email protected]>

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: skshetry <[email protected]>
2023-02-06 13:31:10 +00:00
Peter Rowlands 80c2815e67 deps: bump dvc-data to 0.37.8 2023-02-06 21:18:44 +09:00
Peter Rowlands e42a01a3dd testing: re-add 00 prefix remote traverse tests 2023-02-06 21:18:44 +09:00
177 changed files with 3350 additions and 2720 deletions
+3 -3
View File
@@ -17,9 +17,9 @@ jobs:
strategy:
matrix:
include:
- {pkg: "deb", os: "ubuntu-18.04", asset: "dvc_${{ github.event.release.tag_name }}_amd64.deb"}
- {pkg: "rpm", os: "ubuntu-18.04", asset: "dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm"}
- {pkg: "osxpkg", os: "macos-10.15", asset: "dvc-${{ github.event.release.tag_name }}.pkg"}
- {pkg: "deb", os: "ubuntu-20.04", asset: "dvc_${{ github.event.release.tag_name }}_amd64.deb"}
- {pkg: "rpm", os: "ubuntu-20.04", asset: "dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm"}
- {pkg: "osxpkg", os: "macos-11", asset: "dvc-${{ github.event.release.tag_name }}.pkg"}
- {pkg: "exe", os: "windows-2019", asset: "dvc-${{ github.event.release.tag_name }}.exe"}
name: ${{ matrix.pkg }}
+3 -3
View File
@@ -17,7 +17,7 @@ permissions:
jobs:
tests:
timeout-minutes: 45
timeout-minutes: 50
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -41,9 +41,9 @@ jobs:
pip install -e ".[dev]"
- uses: pre-commit/[email protected]
- name: run tests
timeout-minutes: 30
timeout-minutes: 40
run: >
pytest -nauto --timeout=300 --durations=100
pytest -n=logical --timeout=300 --durations=100
--cov --cov-report=xml --cov-report=term
- name: upload coverage report
uses: codecov/codecov-action@v3
+1 -1
View File
@@ -43,7 +43,7 @@ repos:
hooks:
- id: isort
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.238'
rev: 'v0.0.254'
hooks:
- id: ruff
- repo: https://github.com/codespell-project/codespell
+2 -1
View File
@@ -3,13 +3,14 @@ from dvc.fs.dvc import _DVCFileSystem as DVCFileSystem
from .data import open # pylint: disable=redefined-builtin
from .data import get_url, read
from .experiments import make_checkpoint
from .params import params_show
from .show import metrics_show, params_show
__all__ = [
"get_url",
"make_checkpoint",
"open",
"params_show",
"metrics_show",
"read",
"DVCFileSystem",
]
+150 -33
View File
@@ -4,10 +4,153 @@ from typing import Dict, Iterable, Optional, Union
from funcy import first
from dvc.exceptions import DvcException
from dvc.repo import Repo
def _onerror_raise(exception: Exception, *args, **kwargs):
raise exception
def _postprocess(results):
processed: Dict[str, Dict] = {}
for rev, rev_data in results.items():
if not rev_data:
continue
processed[rev] = {}
counts: typing.Counter[str] = Counter()
for file_data in rev_data["data"].values():
for k in file_data["data"]:
counts[k] += 1
for file_name, file_data in rev_data["data"].items():
to_merge = {
(k if counts[k] == 1 else f"{file_name}:{k}"): v
for k, v in file_data["data"].items()
}
processed[rev] = {**processed[rev], **to_merge}
if "workspace" in processed:
del processed["workspace"]
return processed
def metrics_show(
*targets: str,
repo: Optional[str] = None,
rev: Optional[str] = None,
) -> Dict:
"""Get metrics tracked in `repo`.
Without arguments, this function will retrieve all metrics from all tracked
metric files, for the current working tree.
See the options below to restrict the metrics retrieved.
Args:
*targets (str, optional): Names of the metric files to retrieve
metrics from. For example, "classifier_eval.json,
clustering_eval.json".
If no `targets` are provided, all metric files tracked in `dvc.yaml`
will be used.
Note that targets don't necessarily have to be defined in `dvc.yaml`.
repo (str, optional): Location of the DVC repository.
Defaults to the current project (found by walking up from the
current working directory tree).
It can be a URL or a file system path.
Both HTTP and SSH protocols are supported for online Git repos
(e.g. [user@]server:project.git).
rev (str, optional): Name of the `Git revision`_ to retrieve metrics
from.
Defaults to `None`.
An example of git revision can be a branch or tag name, a commit
hash or a dvc experiment name.
If `repo` is not a Git repo, this option is ignored.
If `None`, the current working tree will be used.
Returns:
Dict: See Examples below.
Examples:
- No arguments.
Working on https://github.com/iterative/example-get-started
>>> import dvc.api
>>> metrics = dvc.api.metrics_show()
>>> print(json.dumps(metrics, indent=4))
{
"avg_prec": 0.9249974999612706,
"roc_auc": 0.9460213440787918
}
---
- Using `rev`.
Working on https://github.com/iterative/example-get-started
>>> import json
>>> import dvc.api
>>> metrics = dvc.api.metrics_show(rev="tune-hyperparams")
>>> print(json.dumps(metrics, indent=4))
{
"avg_prec": 0.9268792615819422,
"roc_auc": 0.945093365854111
}
---
- Using `targets`.
Working on https://github.com/iterative/example-get-started
>>> import json
>>> import dvc.api
>>> metrics = dvc.api.metrics_show("evaluation.json")
>>> print(json.dumps(metrics, indent=4))
{
"avg_prec": 0.9249974999612706,
"roc_auc": 0.9460213440787918
}
---
- Git URL as `repo`.
>>> import json
>>> import dvc.api
>>> metrics = dvc.api.metrics_show(
... repo="https://github.com/iterative/demo-fashion-mnist")
>>> print(json.dumps(metrics, indent=4))
{
"loss": 0.25284987688064575,
"accuracy": 0.9071000218391418
}
.. _Git revision:
https://git-scm.com/docs/revisions
"""
with Repo.open(repo) as _repo:
metrics = _repo.metrics.show(
targets=targets,
revs=rev if rev is None else [rev],
onerror=_onerror_raise,
)
metrics = _postprocess(metrics)
if not metrics:
return {}
return metrics[first(metrics)]
def params_show(
*targets: str,
repo: Optional[str] = None,
@@ -56,9 +199,6 @@ def params_show(
Returns:
Dict: See Examples below.
Raises:
DvcException: If no params are found in `repo`.
Examples:
- No arguments.
@@ -239,34 +379,6 @@ def params_show(
if isinstance(stages, str):
stages = [stages]
def _onerror_raise(_result: Dict, exception: Exception, *args, **kwargs):
raise exception
def _postprocess(params):
processed: Dict[str, Dict] = {}
for rev, rev_data in params.items():
processed[rev] = {}
counts: typing.Counter[str] = Counter()
for file_data in rev_data["data"].values():
for k in file_data["data"]:
counts[k] += 1
for file_name, file_data in rev_data["data"].items():
to_merge = {
(k if counts[k] == 1 else f"{file_name}:{k}"): v
for k, v in file_data["data"].items()
}
processed[rev] = {**processed[rev], **to_merge}
if "workspace" in processed:
del processed["workspace"]
if not processed:
raise DvcException("No params found")
return processed[first(processed)]
with Repo.open(repo) as _repo:
params = _repo.params.show(
revs=rev if rev is None else [rev],
@@ -276,4 +388,9 @@ def params_show(
stages=stages,
)
return _postprocess(params)
params = _postprocess(params)
if not params:
return {}
return params[first(params)]
+10 -4
View File
@@ -1,3 +1,5 @@
import os
from dvc.fs import GitFileSystem, Schemes
from dvc_data.hashfile.db import get_odb
@@ -9,12 +11,11 @@ def _get_odb(repo, settings, fs=None):
return None
cls, config, fs_path = get_cloud_fs(repo, **settings)
config["tmp_dir"] = repo.tmp_dir
fs = fs or cls(**config)
return get_odb(fs, fs_path, state=repo.state, **config)
class ODBManager:
class CacheManager:
CACHE_DIR = "cache"
CLOUD_SCHEMES = [
Schemes.S3,
@@ -29,16 +30,21 @@ class ODBManager:
self.config = config = repo.config["cache"]
self._odb = {}
default = None
if repo and repo.local_dvc_dir:
default = os.path.join(repo.local_dvc_dir, self.CACHE_DIR)
local = config.get("local")
if local:
settings = {"name": local}
elif "dir" not in config:
elif "dir" not in config and not default:
settings = None
else:
from dvc.config_schema import LOCAL_COMMON
settings = {"url": config["dir"]}
url = config.get("dir") or default
settings = {"url": url}
for opt in LOCAL_COMMON:
if opt in config:
settings[str(opt)] = config.get(opt)
+11 -7
View File
@@ -39,12 +39,17 @@ def parse_args(argv=None):
def _log_unknown_exceptions() -> None:
from dvc.info import get_dvc_info
from dvc.logger import FOOTER
from dvc.ui import ui
from dvc.utils import colorize
logger.exception("unexpected error")
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Version info for developers:\n%s", get_dvc_info())
logger.info(FOOTER)
q = colorize("Having any troubles?", "yellow")
link = colorize("https://dvc.org/support", "blue")
footer = f"\n{q} Hit us up at {link}, we are always happy to help!"
ui.error_write(footer)
def _log_exceptions(exc: Exception) -> Optional[int]:
@@ -141,7 +146,7 @@ def _log_exceptions(exc: Exception) -> Optional[int]:
return None
def main(argv=None): # noqa: C901
def main(argv=None): # noqa: C901, PLR0912, PLR0915
"""Main entry point for dvc CLI.
Args:
@@ -153,7 +158,7 @@ def main(argv=None): # noqa: C901
from dvc._debug import debugtools
from dvc.config import ConfigError
from dvc.exceptions import DvcException, NotDvcRepoError
from dvc.logger import disable_other_loggers, set_loggers_level
from dvc.logger import set_loggers_level
# NOTE: stderr/stdout may be closed if we are running from dvc.daemon.
# On Linux we directly call cli.main after double forking and closing
@@ -166,9 +171,8 @@ def main(argv=None): # noqa: C901
logging.disable(logging.INFO)
args = None
disable_other_loggers()
outerLogLevel = logger.level
outer_log_level = logger.level
try:
args = parse_args(argv)
@@ -239,7 +243,7 @@ def main(argv=None): # noqa: C901
return ret
finally:
logger.setLevel(outerLogLevel)
logger.setLevel(outer_log_level)
from dvc.external_repo import clean_repos
+2
View File
@@ -2,6 +2,7 @@
import argparse
import logging
import os
from functools import lru_cache
from dvc import __version__
from dvc.commands import (
@@ -150,6 +151,7 @@ def get_parent_parser():
return parent_parser
@lru_cache(maxsize=1)
def get_main_parser():
parent_parser = get_parent_parser()
+11 -2
View File
@@ -1,4 +1,5 @@
import argparse
import os
from dvc.cli import completion
from dvc.cli.utils import append_doc_link, fix_subparsers
@@ -9,13 +10,21 @@ from dvc.ui import ui
class CmdCacheDir(CmdConfig):
def run(self):
if self.args.value is None and not self.args.unset:
from dvc.config import ConfigError
if self.args.level:
conf = self.config.read(level=self.args.level)
else:
# Use merged config with default values
conf = self.config
self._check(conf, False, "cache", "dir")
ui.write(conf["cache"]["dir"])
try:
self._check(conf, False, "cache", "dir")
path = conf["cache"]["dir"]
except ConfigError:
if not self.config.dvc_dir or self.args.level:
raise
path = os.path.join(self.config.dvc_dir, "cache")
ui.write(path)
return 0
with self.config.edit(level=self.args.level) as conf:
if self.args.unset:
+23 -24
View File
@@ -9,11 +9,11 @@ if TYPE_CHECKING:
from networkx import DiGraph
def _show_ascii(G: "DiGraph"):
def _show_ascii(graph: "DiGraph"):
from dvc.dagascii import draw
from dvc.repo.graph import get_pipelines
pipelines = get_pipelines(G)
pipelines = get_pipelines(graph)
ret = []
for pipeline in pipelines:
@@ -31,7 +31,7 @@ def _quote_label(node):
return label
def _show_dot(G: "DiGraph"):
def _show_dot(graph: "DiGraph"):
import io
import networkx as nx
@@ -39,15 +39,15 @@ def _show_dot(G: "DiGraph"):
dot_file = io.StringIO()
nx.relabel_nodes(G, _quote_label, copy=False)
write_dot(G.reverse(), dot_file)
nx.relabel_nodes(graph, _quote_label, copy=False)
write_dot(graph.reverse(), dot_file)
return dot_file.getvalue()
def _show_mermaid(G, markdown: bool = False):
def _show_mermaid(graph, markdown: bool = False):
from dvc.repo.graph import get_pipelines
pipelines = get_pipelines(G)
pipelines = get_pipelines(graph)
graph = "flowchart TD"
@@ -100,43 +100,42 @@ def _transform(index, outs):
def _relabel(node) -> str:
return node.addressing if isinstance(node, Stage) else str(node)
G = index.outs_graph if outs else index.graph
return nx.relabel_nodes(G, _relabel, copy=True)
graph = index.outs_graph if outs else index.graph
return nx.relabel_nodes(graph, _relabel, copy=True)
def _filter(G, targets, full):
def _filter(graph, targets, full):
import networkx as nx
if not targets:
return G
return graph
H = G.copy()
new_graph = graph.copy()
if not full:
descendants = set()
for target in targets:
descendants.update(nx.descendants(G, target))
descendants.update(nx.descendants(graph, target))
descendants.add(target)
H.remove_nodes_from(set(G.nodes()) - descendants)
new_graph.remove_nodes_from(set(graph.nodes()) - descendants)
undirected = H.to_undirected()
undirected = new_graph.to_undirected()
connected = set()
for target in targets:
connected.update(nx.node_connected_component(undirected, target))
H.remove_nodes_from(set(H.nodes()) - connected)
return H
new_graph.remove_nodes_from(set(new_graph.nodes()) - connected)
return new_graph
def _build(repo, target=None, full=False, outs=False):
targets = _collect_targets(repo, target, outs)
G = _transform(repo.index, outs)
return _filter(G, targets, full)
graph = _transform(repo.index, outs)
return _filter(graph, targets, full)
class CmdDAG(CmdBase):
def run(self):
G = _build(
graph = _build(
self.repo,
target=self.args.target,
full=self.args.full,
@@ -144,12 +143,12 @@ class CmdDAG(CmdBase):
)
if self.args.dot:
ui.write(_show_dot(G))
ui.write(_show_dot(graph))
elif self.args.mermaid or self.args.markdown:
ui.write(_show_mermaid(G, self.args.markdown))
ui.write(_show_mermaid(graph, self.args.markdown))
else:
with ui.pager():
ui.write(_show_ascii(G))
ui.write(_show_ascii(graph))
return 0
+10
View File
@@ -21,12 +21,14 @@ logger = logging.getLogger(__name__)
class CmdDataStatus(CmdBase):
COLORS = {
"not_in_remote": "red",
"not_in_cache": "red",
"committed": "green",
"uncommitted": "yellow",
"untracked": "cyan",
}
LABELS = {
"not_in_remote": "Not in remote",
"not_in_cache": "Not in cache",
"committed": "DVC committed changes",
"uncommitted": "DVC uncommitted changes",
@@ -34,6 +36,7 @@ class CmdDataStatus(CmdBase):
"unchanged": "DVC unchanged files",
}
HINTS = {
"not_in_remote": ('use "dvc push <file>..." to upload files',),
"not_in_cache": ('use "dvc fetch <file>..." to download files',),
"committed": ("git commit the corresponding dvc files to update the repo",),
"uncommitted": (
@@ -114,6 +117,7 @@ class CmdDataStatus(CmdBase):
status = self.repo.data_status(
granular=self.args.granular,
untracked_files=self.args.untracked_files,
remote_refresh=self.args.remote_refresh,
)
if not self.args.unchanged:
@@ -239,6 +243,12 @@ def add_parser(subparsers, parent_parser):
nargs="?",
help="Show untracked files.",
)
data_status_parser.add_argument(
"--remote-refresh",
action="store_true",
default=False,
help="Refresh remote index.",
)
data_status_parser.set_defaults(func=CmdDataStatus)
DATA_LS_HELP = "List data tracked by DVC with its metadata."
+7
View File
@@ -40,6 +40,7 @@ class CmdDataPull(CmdDataBase):
recursive=self.args.recursive,
run_cache=self.args.run_cache,
glob=self.args.glob,
allow_missing=self.args.allow_missing,
)
self.log_summary(stats)
except (CheckoutError, DvcException) as exc:
@@ -197,6 +198,12 @@ def add_parser(subparsers, _parent_parser):
default=False,
help=argparse.SUPPRESS,
)
pull_parser.add_argument(
"--allow-missing",
action="store_true",
default=False,
help="Ignore errors if some of the files or directories are missing.",
)
pull_parser.set_defaults(func=CmdDataPull)
# Push
+2
View File
@@ -4,6 +4,7 @@ from dvc.cli.utils import append_doc_link, fix_subparsers, hide_subparsers_from_
from dvc.commands.experiments import (
apply,
branch,
clean,
diff,
exec_run,
gc,
@@ -21,6 +22,7 @@ from dvc.commands.experiments import (
SUB_COMMANDS = [
apply,
branch,
clean,
diff,
exec_run,
gc,
+9 -1
View File
@@ -4,13 +4,21 @@ import logging
from dvc.cli import completion
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.ui import ui
logger = logging.getLogger(__name__)
class CmdExperimentsApply(CmdBase):
def run(self):
self.repo.experiments.apply(self.args.experiment, force=self.args.force)
if not self.args.force:
ui.write(
"The --no-force option is deprecated and will be removed in a future"
" DVC release. To revert the result of 'exp apply', run:\n"
"\n\tgit reset --hard\n"
"\tgit stash apply refs/exps/apply/stash\n"
)
self.repo.experiments.apply(self.args.experiment)
return 0
+25
View File
@@ -0,0 +1,25 @@
import argparse
import logging
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
logger = logging.getLogger(__name__)
class CmdExperimentsClean(CmdBase):
def run(self):
self.repo.experiments.clean()
return 0
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_CLEAN_HELP = "Cleanup dvc exp internal tempfiles."
experiments_clean_parser = experiments_subparsers.add_parser(
"clean",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_CLEAN_HELP, "exp/clean"),
help=EXPERIMENTS_CLEAN_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_clean_parser.set_defaults(func=CmdExperimentsClean)
+8 -1
View File
@@ -9,7 +9,9 @@ class CmdQueueWorker(CmdBase):
"""Run the exp queue worker."""
def run(self):
self.repo.experiments.celery_queue.worker.start(self.args.name)
self.repo.experiments.celery_queue.worker.start(
self.args.name, fsapp_clean=self.args.clean
)
return 0
@@ -22,4 +24,9 @@ def add_parser(experiments_subparsers, parent_parser):
add_help=False,
)
parser.add_argument("name", help="Celery worker name.")
parser.add_argument(
"--clean",
action="store_true",
help="Automatically cleanup celery broker on shutdown.",
)
parser.set_defaults(func=CmdQueueWorker)
+14 -3
View File
@@ -62,7 +62,7 @@ experiment_types = {
}
def _collect_rows( # noqa: C901
def _collect_rows( # noqa: C901, PLR0912, PLR0913, PLR0915
base_rev,
experiments,
all_headers,
@@ -244,7 +244,7 @@ def _extend_row(row, names, headers, items, precision, fill_value=FILL_VALUE):
row[f"{fname}:{name}"] = value
def experiments_table(
def experiments_table( # noqa: PLR0913
all_experiments,
headers,
metric_headers,
@@ -308,7 +308,7 @@ def baseline_styler(typ):
return {"style": "bold"} if typ == "baseline" else {}
def show_experiments( # noqa: C901
def show_experiments( # noqa: C901, PLR0912
all_experiments,
keep=None,
drop=None,
@@ -453,8 +453,12 @@ def _normalize_headers(names, count):
def _format_json(item):
from dvc.repo.experiments.show import _CachedError
if isinstance(item, (date, datetime)):
return item.isoformat()
if isinstance(item, _CachedError):
return {"type": getattr(item, "typ", "_CachedError"), "msg": str(item)}
return encode_exception(item)
@@ -472,6 +476,7 @@ class CmdExperimentsShow(CmdBase):
sha_only=self.args.sha,
param_deps=self.args.param_deps,
fetch_running=self.args.fetch_running,
force=self.args.force,
)
except DvcException:
logger.exception("failed to show experiments")
@@ -650,4 +655,10 @@ def add_parser(experiments_subparsers, parent_parser):
action="store_false",
help=argparse.SUPPRESS,
)
experiments_show_parser.add_argument(
"-f",
"--force",
action="store_true",
help="Force re-collection of experiments instead of loading from exp cache.",
)
experiments_show_parser.set_defaults(func=CmdExperimentsShow)
+1 -1
View File
@@ -5,7 +5,6 @@ from dvc.cli import completion
from dvc.cli.command import CmdBaseNoRepo
from dvc.cli.utils import append_doc_link
from dvc.exceptions import DvcException
from dvc.scm import CloneError
logger = logging.getLogger(__name__)
@@ -32,6 +31,7 @@ class CmdGet(CmdBaseNoRepo):
def _get_file_from_repo(self):
from dvc.repo import Repo
from dvc.scm import CloneError
try:
Repo.get(
+2 -1
View File
@@ -5,13 +5,14 @@ from dvc.cli import completion
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.exceptions import DvcException
from dvc.scm import CloneError
logger = logging.getLogger(__name__)
class CmdImport(CmdBase):
def run(self):
from dvc.scm import CloneError
try:
self.repo.imp(
self.args.url,
+1 -1
View File
@@ -302,7 +302,7 @@ class CmdMachineSsh(CmdBase):
return 0
def add_parser(subparsers, parent_parser):
def add_parser(subparsers, parent_parser): # noqa: PLR0915
from dvc.commands.config import parent_config_parser
machine_HELP = "Set up and manage cloud machines."
+3 -14
View File
@@ -76,10 +76,6 @@ class CmdPlots(CmdBase):
props = {p: getattr(self.args, p) for p in PLOT_PROPS}
return {k: v for k, v in props.items() if v is not None}
def _config_files(self):
if self.args.from_config:
return {self.args.from_config}
def _html_template_path(self):
html_template_path = self.args.html_template
if not html_template_path:
@@ -91,7 +87,7 @@ class CmdPlots(CmdBase):
html_template_path = os.path.join(self.repo.dvc_dir, html_template_path)
return html_template_path # noqa: RET504
def run(self) -> int: # noqa: C901
def run(self) -> int: # noqa: C901, PLR0911, PLR0912
from pathlib import Path
from dvc.render.match import match_defs_renderers
@@ -114,10 +110,9 @@ class CmdPlots(CmdBase):
plots_data = self._func(
targets=self.args.targets,
props=self._props(),
config_files=self._config_files(),
)
if not plots_data:
if not plots_data and not self.args.json:
ui.error_write(
"No plots were loaded, visualization file will not be created."
)
@@ -150,7 +145,7 @@ class CmdPlots(CmdBase):
render_html(
renderers=renderers,
output_file=output_file,
template_path=self._html_template_path(),
html_template=self._html_template_path(),
)
ui.write(output_file.as_uri())
@@ -412,9 +407,3 @@ def _add_ui_arguments(parser):
help="Custom HTML template for VEGA visualization.",
metavar="<path>",
)
parser.add_argument(
"--from-config",
default=None,
metavar="<path>",
help=argparse.SUPPRESS,
)
+1 -2
View File
@@ -31,8 +31,7 @@ class CmdUpdate(CmdBase):
def add_parser(subparsers, parent_parser):
UPDATE_HELP = (
"Update data artifact imported (via dvc import or dvc import-url) "
"from an external DVC repository or URL, or update data from a "
"DVC worktree remote."
"from an external DVC repository or URL."
)
update_parser = subparsers.add_parser(
"update",
+2 -2
View File
@@ -209,7 +209,7 @@ class TabularData(MutableSequence[Sequence["CellT"]]):
keys = self.keys() if cols is None else set(cols)
return [{k: self._columns[k][i] for k in keys} for i in range(len(self))]
def dropna( # noqa: C901
def dropna( # noqa: C901, PLR0912
self,
axis: str = "rows",
how="any",
@@ -371,7 +371,7 @@ def diff_table(
return td
def show_diff(
def show_diff( # noqa: PLR0913
diff,
title: str,
old: bool = True,
+1 -5
View File
@@ -117,7 +117,7 @@ class Config(dict):
@classmethod
def get_dir(cls, level):
from appdirs import site_config_dir, user_config_dir
from platformdirs import site_config_dir, user_config_dir
assert level in ("global", "system")
@@ -170,10 +170,6 @@ class Config(dict):
self.clear()
self.update(conf)
# Add resolved default cache.dir
if not self["cache"].get("dir") and self.dvc_dir:
self["cache"]["dir"] = os.path.join(self.dvc_dir, "cache")
def _get_fs(self, level):
# NOTE: this might be a Gitfs, which doesn't see things outside of
# the repo.
+5 -2
View File
@@ -30,7 +30,7 @@ def supported_cache_type(types):
return types
def Choices(*choices):
def Choices(*choices): # noqa: N802
"""Checks that value belongs to the specified set of values
Args:
@@ -40,7 +40,7 @@ def Choices(*choices):
return Any(*choices, msg="expected one of {}".format(", ".join(choices)))
def ByUrl(mapping):
def ByUrl(mapping): # noqa: N802
schemas = walk_values(Schema, mapping)
def validate(data):
@@ -51,6 +51,8 @@ def ByUrl(mapping):
# Windows absolute paths should really have scheme == "" (local)
if os.name == "nt" and len(parsed.scheme) == 1 and parsed.netloc == "":
return schemas[""](data)
if parsed.netloc == "":
return schemas[""](data)
if parsed.scheme not in schemas:
raise Invalid(f"Unsupported URL type {parsed.scheme}://")
@@ -263,6 +265,7 @@ SCHEMA = {
"feature": {
Optional("machine", default=False): Bool,
# enabled by default. It's of no use, kept for backward compatibility.
Optional("data_index_cache", default=False): Bool,
Optional("parametrization", default=True): Bool,
},
"plots": {
+12 -2
View File
@@ -14,7 +14,14 @@ from dvc.utils import fix_env, is_binary
logger = logging.getLogger(__name__)
def _popen(cmd, **kwargs):
def _suppress_resource_warning(popen: Popen):
"""Sets the returncode to avoid ResourceWarning when popen is garbage collected."""
# only use for daemon processes.
# See https://bugs.python.org/issue38890.
popen.returncode = 0
def _popen(cmd, **kwargs) -> Popen:
prefix = [sys.executable]
if not is_binary():
main_entrypoint = os.path.join(
@@ -40,7 +47,10 @@ def _spawn_windows(cmd, env):
startupinfo = STARTUPINFO()
startupinfo.dwFlags |= STARTF_USESHOWWINDOW
_popen(cmd, env=env, creationflags=creationflags, startupinfo=startupinfo)
popen = _popen(
cmd, env=env, creationflags=creationflags, startupinfo=startupinfo
)
_suppress_resource_warning(popen)
def _spawn_posix(cmd, env):
+3 -3
View File
@@ -80,7 +80,7 @@ class AsciiCanvas:
self.canvas[y][x] = char
def line(self, x0, y0, x1, y1, char): # noqa: C901
def line(self, x0, y0, x1, y1, char): # noqa: C901, PLR0912
"""Create a line on ASCII canvas.
Args:
@@ -213,8 +213,8 @@ def draw(vertices, edges):
# pylint: disable=too-many-locals
# NOTE: coordinates might me negative, so we need to shift
# everything to the positive plane before we actually draw it.
Xs = [] # pylint: disable=invalid-name
Ys = [] # pylint: disable=invalid-name
Xs = [] # noqa: N806, pylint: disable=invalid-name
Ys = [] # noqa: N806, pylint: disable=invalid-name
sug = _build_sugiyama_layout(vertices, edges)
+14 -9
View File
@@ -18,10 +18,11 @@ logger = logging.getLogger(__name__)
class Remote:
def __init__(self, name: str, path: str, fs: "FileSystem", **config):
def __init__(self, name: str, path: str, fs: "FileSystem", *, index=None, **config):
self.path = path
self.fs = fs
self.name = name
self.index = index
self.worktree: bool = config.pop("worktree", False)
self.config = config
@@ -34,7 +35,7 @@ class Remote:
if self.worktree:
path = self.fs.path.join(path, ".dvc", "cache")
return get_odb(self.fs, path, **self.config)
return get_odb(self.fs, path, hash_name="md5", **self.config)
class DataCloud:
@@ -75,7 +76,11 @@ class DataCloud:
fs = cls(**config)
config["tmp_dir"] = self.repo.index_db_dir
return Remote(name, fs_path, fs, **config)
if self.repo.data_index is not None:
index = self.repo.data_index.view(("remote", name))
else:
index = None
return Remote(name, fs_path, fs, index=index, **config)
if bool(self.repo.config["remote"]):
error_msg = (
@@ -147,12 +152,12 @@ class DataCloud:
"""
odb = odb or self.get_remote_odb(remote, "push")
return self.transfer(
self.repo.odb.local,
self.repo.cache.local,
odb,
objs,
jobs=jobs,
dest_index=get_index(odb),
cache_odb=self.repo.odb.local,
cache_odb=self.repo.cache.local,
validate_status=self._log_missing,
)
@@ -175,11 +180,11 @@ class DataCloud:
odb = odb or self.get_remote_odb(remote, "pull")
return self.transfer(
odb,
self.repo.odb.local,
self.repo.cache.local,
objs,
jobs=jobs,
src_index=get_index(odb),
cache_odb=self.repo.odb.local,
cache_odb=self.repo.cache.local,
verify=odb.verify,
validate_status=self._log_missing,
)
@@ -206,12 +211,12 @@ class DataCloud:
if not odb:
odb = self.get_remote_odb(remote, "status")
return compare_status(
self.repo.odb.local,
self.repo.cache.local,
odb,
objs,
jobs=jobs,
dest_index=get_index(odb),
cache_odb=self.repo.odb.local,
cache_odb=self.repo.cache.local,
)
def get_url_for(self, remote, checksum):
+7 -6
View File
@@ -2,9 +2,9 @@ import logging
import os
import typing
from collections import defaultdict
from typing import Dict, cast
from typing import Dict
import dpath.util
import dpath
from voluptuous import Any
from dvc.exceptions import DvcException
@@ -87,17 +87,17 @@ class ParamsDependency(Dependency):
if flatten:
for param in self.params:
try:
ret[param] = dpath.util.get(config, param, separator=".")
ret[param] = dpath.get(config, param, separator=".")
except KeyError:
continue
return ret
from dpath.util import merge
from dpath import merge
for param in self.params:
merge(
ret,
dpath.util.search(config, param, separator="."),
dpath.search(config, param, separator="."),
separator=".",
)
return ret
@@ -111,7 +111,8 @@ class ParamsDependency(Dependency):
from funcy import ldistinct
status: Dict[str, Any] = defaultdict(dict)
info = cast(dict, self.hash_info.value) if self.hash_info else {}
assert isinstance(self.hash_info.value, dict)
info = self.hash_info.value if self.hash_info else {}
actual = self.read_params()
# NOTE: we want to preserve the order of params as specified in the
+3 -3
View File
@@ -80,7 +80,7 @@ class RepoDependency(Dependency):
to.fs_path,
to.fs,
obj,
self.repo.odb.local,
self.repo.cache.local,
ignore=None,
state=self.repo.state,
prompt=confirm,
@@ -111,7 +111,7 @@ class RepoDependency(Dependency):
from dvc_data.hashfile.build import build
from dvc_data.hashfile.tree import Tree, TreeError
local_odb = self.repo.odb.local
local_odb = self.repo.cache.local
locked = kwargs.pop("locked", True)
with self._make_repo(locked=locked, cache_dir=local_odb.path) as repo:
used_obj_ids = defaultdict(set)
@@ -133,7 +133,7 @@ class RepoDependency(Dependency):
self._check_circular_import(odb, obj_ids)
used_obj_ids[odb].update(obj_ids)
except (NoRemoteError, NoOutputOrStageError):
pass # noqa: S110
pass
try:
object_store, meta, obj = build(
+6 -7
View File
@@ -187,7 +187,7 @@ class FileMixin:
class SingleStageFile(FileMixin):
from dvc.schema import COMPILED_SINGLE_STAGE_SCHEMA as SCHEMA
from dvc.stage.loader import SingleStageLoader as LOADER
from dvc.stage.loader import SingleStageLoader as LOADER # noqa: N814
metrics: List[str] = []
plots: Any = {}
@@ -231,7 +231,7 @@ class ProjectFile(FileMixin):
"""Abstraction for pipelines file, .yaml + .lock combined."""
from dvc.schema import COMPILED_MULTI_STAGE_SCHEMA as SCHEMA
from dvc.stage.loader import StageLoader as LOADER
from dvc.stage.loader import StageLoader as LOADER # noqa: N814
@property
def _lockfile(self):
@@ -409,11 +409,10 @@ class Lockfile(FileMixin):
if version == LOCKFILE_VERSION.V1:
logger.info("Migrating lock file '%s' from v1 to v2", self.relpath)
migrate_lock_v1_to_v2(data, self.latest_version_info)
else:
if not data:
data.update(self.latest_version_info)
# order is important, meta should always be at the top
logger.info("Generating lock file '%s'", self.relpath)
elif not data:
data.update(self.latest_version_info)
# order is important, meta should always be at the top
logger.info("Generating lock file '%s'", self.relpath)
data["stages"] = data.get("stages", {})
modified = data["stages"].get(stage.name, {}) != stage_data.get(
+2 -2
View File
@@ -149,7 +149,7 @@ def _get_remote_config(url):
name = "auto-generated-upstream"
return {
"core": {"remote": name},
"remote": {name: {"url": repo.odb.local.path}},
"remote": {name: {"url": repo.cache.local.path}},
}
# Use original remote to make sure that we are using correct url,
@@ -189,7 +189,7 @@ def _cached_clone(url, rev, for_write=False):
@wrap_with(threading.Lock())
def _clone_default_branch(url, rev, for_write=False): # noqa: C901
def _clone_default_branch(url, rev, for_write=False): # noqa: C901, PLR0912
"""Get or create a clean clone of the url.
The cloned is reactualized with git pull unless rev is a known sha.
+1 -1
View File
@@ -6,7 +6,6 @@ from dvc.config import ConfigError as RepoConfigError
from dvc.config_schema import SCHEMA, Invalid
# pylint: disable=unused-import
from dvc_objects.fs import utils # noqa: F401
from dvc_objects.fs import ( # noqa: F401
LocalFileSystem,
MemoryFileSystem,
@@ -17,6 +16,7 @@ from dvc_objects.fs import ( # noqa: F401
localfs,
registry,
system,
utils,
)
from dvc_objects.fs.base import AnyFSPath, FileSystem # noqa: F401
from dvc_objects.fs.errors import ( # noqa: F401
+3 -2
View File
@@ -1,6 +1,6 @@
# pylint: disable=unused-import
from contextlib import ExitStack
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, Optional, Union, cast
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, Optional, Union
from dvc.utils.objects import cached_property
from dvc_objects.fs.callbacks import ( # noqa: F401
@@ -54,7 +54,8 @@ class RichCallback(Callback):
disable=self.disable,
console=ui.error_console,
)
return cast(RichTransferProgress, self._stack.enter_context(progress))
self._stack.enter_context(progress)
return progress
@cached_property
def task(self) -> "TaskID":
+17 -2
View File
@@ -289,7 +289,7 @@ class _DVCFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
):
fs_infos[fs.path.name(info["name"])] = info
except (FileNotFoundError, NotADirectoryError):
pass # noqa: S110
pass
dvcfiles = kwargs.get("dvcfiles", False)
@@ -316,7 +316,9 @@ class _DVCFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
ignore_subrepos = kwargs.get("ignore_subrepos", True)
return self._info(key, path, ignore_subrepos=ignore_subrepos)
def _info(self, key, path, ignore_subrepos=True, check_ignored=True): # noqa: C901
def _info( # noqa: C901, PLR0912
self, key, path, ignore_subrepos=True, check_ignored=True
):
repo, dvc_fs, subkey = self._get_subrepo_info(key)
dvc_info = None
@@ -359,6 +361,19 @@ class _DVCFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
info["name"] = path
return info
def get_file(self, rpath, lpath, **kwargs): # pylint: disable=arguments-differ
key = self._get_key_from_relative(rpath)
fs_path = self._from_key(key)
try:
return self.repo.fs.get_file(fs_path, lpath, **kwargs)
except FileNotFoundError:
_, dvc_fs, subkey = self._get_subrepo_info(key)
if not dvc_fs:
raise
dvc_path = _get_dvc_path(dvc_fs, subkey)
return dvc_fs.get_file(dvc_path, lpath, **kwargs)
class DVCFileSystem(FileSystem):
protocol = "local"
+4 -5
View File
@@ -19,7 +19,6 @@ SUBPROJECTS = (
"dvc_objects",
"dvc_render",
"dvc_task",
"dvclive",
"scmrepo",
)
package = "" if PKG is None else f" ({PKG})"
@@ -41,14 +40,14 @@ def get_dvc_info():
# can't auto-create it, as it might cause issues if the user
# later decides to enable shared cache mode with
# `dvc config cache.shared group`.
if os.path.exists(repo.odb.local.path):
if os.path.exists(repo.cache.local.path):
info.append(f"Cache types: {_get_linktype_support_info(repo)}")
fs_type = get_fs_type(repo.odb.local.path)
fs_type = get_fs_type(repo.cache.local.path)
info.append(f"Cache directory: {fs_type}")
else:
info.append("Cache types: " + error_link("no-dvc-cache"))
info.append(f"Caches: {_get_caches(repo.odb)}")
info.append(f"Caches: {_get_caches(repo.cache)}")
info.append(f"Remotes: {_get_remotes(repo.config)}")
root_directory = repo.root_dir
@@ -84,7 +83,7 @@ def _get_remotes(config):
def _get_linktype_support_info(repo):
odb = repo.odb.local
odb = repo.cache.local
links = generic.test_links(
["reflink", "hardlink", "symlink"],
+97 -170
View File
@@ -1,63 +1,55 @@
"""Manages logging configuration for DVC repo."""
import logging
import logging.config
import logging.handlers
import sys
import colorama
from dvc.progress import Tqdm
FOOTER = (
"\n{yellow}Having any troubles?{nc}"
" Hit us up at {blue}https://dvc.org/support{nc},"
" we are always happy to help!"
).format(
blue=colorama.Fore.BLUE,
nc=colorama.Fore.RESET,
yellow=colorama.Fore.YELLOW,
)
def addLoggingLevel(levelName, levelNum, methodName=None):
def add_logging_level(level_name, level_num, method_name=None):
"""
Adds a new logging level to the `logging` module and the
currently configured logging class.
Uses the existing numeric levelNum if already defined.
Uses the existing numeric level_num if already defined.
Based on https://stackoverflow.com/questions/2183233
"""
if methodName is None:
methodName = levelName.lower()
if method_name is None:
method_name = level_name.lower()
# If the level name is already defined as a top-level `logging`
# constant, then adopt the existing numeric level.
if hasattr(logging, levelName):
existingLevelNum = getattr(logging, levelName)
assert isinstance(existingLevelNum, int)
levelNum = existingLevelNum
if hasattr(logging, level_name):
existing_level_num = getattr(logging, level_name)
assert isinstance(existing_level_num, int)
level_num = existing_level_num
def logForLevel(self, message, *args, **kwargs):
if self.isEnabledFor(levelNum):
def log_for_level(self, message, *args, **kwargs):
if self.isEnabledFor(level_num):
# pylint: disable=protected-access
self._log(levelNum, message, args, **kwargs)
self._log(level_num, message, args, **kwargs)
def logToRoot(message, *args, **kwargs):
logging.log(levelNum, message, *args, **kwargs)
def log_to_root(message, *args, **kwargs):
logging.log(level_num, message, *args, **kwargs)
# getLevelName resolves the numeric log level if already defined,
# otherwise returns a string
if not isinstance(logging.getLevelName(levelName), int):
logging.addLevelName(levelNum, levelName)
if not isinstance(logging.getLevelName(level_name), int):
logging.addLevelName(level_num, level_name)
if not hasattr(logging, levelName):
setattr(logging, levelName, levelNum)
if not hasattr(logging, level_name):
setattr(logging, level_name, level_num)
if not hasattr(logging.getLoggerClass(), methodName):
setattr(logging.getLoggerClass(), methodName, logForLevel)
if not hasattr(logging.getLoggerClass(), method_name):
setattr(logging.getLoggerClass(), method_name, log_for_level)
if not hasattr(logging, methodName):
setattr(logging, methodName, logToRoot)
if not hasattr(logging, method_name):
setattr(logging, method_name, log_to_root)
class LoggingException(Exception):
@@ -66,12 +58,11 @@ class LoggingException(Exception):
super().__init__(msg)
def excludeFilter(level):
class ExcludeLevelFilter(logging.Filter):
def filter(self, record): # noqa: A003
return record.levelno < level
def exclude_filter(level: int):
def filter_fn(record: "logging.LogRecord") -> bool:
return record.levelno < level
return ExcludeLevelFilter
return filter_fn
class ColorFormatter(logging.Formatter):
@@ -86,7 +77,8 @@ class ColorFormatter(logging.Formatter):
exception cause, just the message and the traceback.
"""
color_code = {
reset = colorama.Fore.RESET
color_codes = {
"TRACE": colorama.Fore.GREEN,
"DEBUG": colorama.Fore.BLUE,
"WARNING": colorama.Fore.YELLOW,
@@ -94,51 +86,50 @@ class ColorFormatter(logging.Formatter):
"CRITICAL": colorama.Fore.RED,
}
def format(self, record): # noqa: A003
def __init__(self, log_colors: bool = True) -> None:
super().__init__()
self.log_colors = log_colors
def format(self, record) -> str: # noqa: A003, C901
record.message = record.getMessage()
msg = self.formatMessage(record)
if record.levelname == "INFO":
if record.levelno == logging.INFO:
return msg
if record.exc_info:
if getattr(record, "tb_only", False):
cause = ""
else:
cause = ": ".join(_iter_causes(record.exc_info[1]))
ei = record.exc_info
if ei:
cause = ""
if not getattr(record, "tb_only", False):
cause = ": ".join(_iter_causes(ei[1]))
sep = " - " if msg and cause else ""
msg = msg + sep + cause
msg = "{message}{separator}{cause}".format(
message=msg or "",
separator=" - " if msg and cause else "",
cause=cause,
)
asctime = ""
if _is_verbose():
asctime = self.formatTime(record, self.datefmt)
if ei and not record.exc_text:
record.exc_text = self.formatException(ei)
if record.exc_text:
if msg[-1:] != "\n":
msg = msg + "\n"
msg = msg + record.exc_text + "\n"
if record.stack_info:
if msg[-1:] != "\n":
msg = msg + "\n"
msg = msg + self.formatStack(record.stack_info) + "\n"
if _is_verbose():
msg += _stack_trace(record.exc_info)
return "{asctime}{color}{levelname}{nc}: {msg}".format(
asctime=self.formatTime(record, self.datefmt),
color=self.color_code[record.levelname],
nc=colorama.Fore.RESET,
levelname=record.levelname,
msg=msg,
)
def formatTime(self, record, datefmt=None):
# only show if current level is set to DEBUG
# also, skip INFO as it is used for UI
if not _is_verbose() or record.levelno == logging.INFO:
return ""
return "{green}{date}{nc} ".format(
green=colorama.Fore.GREEN,
date=super().formatTime(record, datefmt),
nc=colorama.Fore.RESET,
)
level = record.levelname
if self.log_colors:
color = self.color_codes[level]
if asctime:
asctime = color + asctime + self.reset
level = color + level + self.reset
return asctime + (" " if asctime else "") + level + ": " + msg
class LoggerHandler(logging.StreamHandler):
def handleError(self, record):
def handleError(self, record): # noqa: N802
super().handleError(record)
raise LoggingException(record)
@@ -156,7 +147,7 @@ class LoggerHandler(logging.StreamHandler):
if not _is_verbose():
return
except Exception: # noqa, pylint: disable=broad-except
pass # noqa: S110
pass
msg = self.format(record)
Tqdm.write(msg, file=self.stream, end=getattr(self, "terminator", "\n"))
@@ -179,31 +170,44 @@ def _iter_causes(exc):
exc = exc.__cause__
def _stack_trace(exc_info):
import traceback
return "\n{red}{line}{nc}\n{trace}{red}{line}{nc}".format(
red=colorama.Fore.RED,
line="-" * 60,
trace="".join(traceback.format_exception(*exc_info)),
nc=colorama.Fore.RESET,
)
def disable_other_loggers():
logging.captureWarnings(True)
loggerDict = logging.root.manager.loggerDict # pylint: disable=no-member
for logger_name, logger in loggerDict.items():
if logger_name != "dvc" and not logger_name.startswith("dvc."):
logger.disabled = True # type: ignore[union-attr]
def set_loggers_level(level: int = logging.INFO) -> None:
for name in ["dvc", "dvc_objects", "dvc_data"]:
logging.getLogger(name).setLevel(level)
def setup(level: int = logging.INFO) -> None:
def setup(level: int = logging.INFO, log_colors: bool = True) -> None:
colorama.init()
formatter = ColorFormatter(log_colors=log_colors and sys.stdout.isatty())
console_info = LoggerHandler(sys.stdout)
console_info.setLevel(logging.INFO)
console_info.setFormatter(formatter)
console_info.addFilter(exclude_filter(logging.WARNING))
console_debug = LoggerHandler(sys.stdout)
console_debug.setLevel(logging.DEBUG)
console_debug.setFormatter(formatter)
console_debug.addFilter(exclude_filter(logging.INFO))
add_logging_level("TRACE", logging.DEBUG - 5)
console_trace = LoggerHandler(sys.stdout)
console_trace.setLevel(logging.TRACE) # type: ignore[attr-defined]
console_trace.setFormatter(formatter)
console_trace.addFilter(exclude_filter(logging.DEBUG))
err_formatter = ColorFormatter(log_colors=log_colors and sys.stderr.isatty())
console_errors = LoggerHandler(sys.stderr)
console_errors.setLevel(logging.WARNING)
console_errors.setFormatter(err_formatter)
for name in ["dvc", "dvc_objects", "dvc_data"]:
logger = logging.getLogger(name)
logger.setLevel(level)
for handler in [console_info, console_debug, console_trace, console_errors]:
logger.addHandler(handler)
if level >= logging.DEBUG:
# Unclosed session errors for asyncio/aiohttp are only available
# on the tracing mode for extensive debug purposes. They are really
@@ -214,80 +218,3 @@ def setup(level: int = logging.INFO) -> None:
# action.
logging.getLogger("asyncio").setLevel(logging.CRITICAL)
logging.getLogger("aiohttp").setLevel(logging.CRITICAL)
addLoggingLevel("TRACE", logging.DEBUG - 5)
logging.config.dictConfig(
{
"version": 1,
"filters": {
"exclude_errors": {"()": excludeFilter(logging.WARNING)},
"exclude_info": {"()": excludeFilter(logging.INFO)},
"exclude_debug": {"()": excludeFilter(logging.DEBUG)},
},
"formatters": {"color": {"()": ColorFormatter}},
"handlers": {
"console_info": {
"class": "dvc.logger.LoggerHandler",
"level": "INFO",
"formatter": "color",
"stream": "ext://sys.stdout",
"filters": ["exclude_errors"],
},
"console_debug": {
"class": "dvc.logger.LoggerHandler",
"level": "DEBUG",
"formatter": "color",
"stream": "ext://sys.stdout",
"filters": ["exclude_info"],
},
"console_trace": {
"class": "dvc.logger.LoggerHandler",
"level": "TRACE",
"formatter": "color",
"stream": "ext://sys.stdout",
"filters": ["exclude_debug"],
},
"console_errors": {
"class": "dvc.logger.LoggerHandler",
"level": "WARNING",
"formatter": "color",
"stream": "ext://sys.stderr",
},
},
"loggers": {
"dvc": {
"level": level,
"handlers": [
"console_info",
"console_debug",
"console_trace",
"console_errors",
],
},
"dvc_objects": {
"level": level,
"handlers": [
"console_info",
"console_debug",
"console_trace",
"console_errors",
],
},
"dvc_data": {
"level": level,
"handlers": [
"console_info",
"console_debug",
"console_trace",
"console_errors",
],
},
},
"disable_existing_loggers": False,
}
)
# NOTE: colorama init needs to go after logging config to avoid potential
# conflicts with libs that modify logging (like tensorflow/abseil)
# https://github.com/iterative/dvc/issues/8387
colorama.init()
+87 -78
View File
@@ -1,7 +1,10 @@
import logging
import os
import posixpath
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type, cast
from contextlib import suppress
from operator import itemgetter
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type
from urllib.parse import urlparse
from funcy import collecting, first, project
@@ -18,7 +21,6 @@ from dvc.exceptions import (
RemoteCacheRequiredError,
)
from dvc.utils.objects import cached_property
from dvc_data.hashfile import Tree
from dvc_data.hashfile import check as ocheck
from dvc_data.hashfile import load as oload
from dvc_data.hashfile.build import build
@@ -27,7 +29,7 @@ from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.hashfile.istextfile import istextfile
from dvc_data.hashfile.meta import Meta
from dvc_data.hashfile.transfer import transfer as otransfer
from dvc_data.index import DataIndexEntry
from dvc_data.hashfile.tree import Tree
from dvc_objects.errors import ObjectFormatError
from .annotations import ANNOTATION_FIELDS, ANNOTATION_SCHEMA, Annotation
@@ -37,6 +39,7 @@ from .utils import relpath
from .utils.fs import path_isin
if TYPE_CHECKING:
from dvc_data.hashfile.db import HashFileDB
from dvc_data.hashfile.obj import HashFile
from dvc_data.index import DataIndexKey
from dvc_objects.db import ObjectDB
@@ -229,6 +232,17 @@ def merge_file_meta_from_cloud(entry: Dict) -> Dict:
return entry
def _serialize_tree_obj_to_files(obj: "Tree") -> List[Dict[str, Any]]:
key = obj.PARAM_RELPATH
return sorted(
(
{key: posixpath.sep.join(parts), **hi.to_dict(), **meta.to_dict()}
for parts, meta, hi in obj
),
key=itemgetter(key),
)
class OutputDoesNotExistError(DvcException):
def __init__(self, path):
msg = f"output '{path}' does not exist"
@@ -299,7 +313,7 @@ class Output:
IsStageFileError: Type[DvcException] = OutputIsStageFileError
IsIgnoredError: Type[DvcException] = OutputIsIgnoredError
def __init__(
def __init__( # noqa: PLR0913
self,
stage,
path,
@@ -381,6 +395,7 @@ class Output:
self.fs_path = self._parse_path(self.fs, fs_path)
self.obj: Optional["HashFile"] = None
self.odb: Optional["HashFileDB"] = None
self.remote = remote
if self.fs.version_aware:
@@ -402,8 +417,13 @@ class Output:
def _compute_meta_hash_info_from_files(self) -> None:
if self.files:
from dvc_data.hashfile.db import HashFileDB
tree = Tree.from_list(self.files, hash_name=self.hash_name)
tree.digest()
tree.digest(with_meta=True)
self.odb = HashFileDB(tree.fs, tree.path + ".odb")
self.odb.add(tree.path, tree.fs, tree.hash_info.value)
self.hash_info = tree.hash_info
self.meta.isdir = True
self.meta.nfiles = len(self.files)
@@ -487,16 +507,18 @@ class Output:
return self.use_cache or self.stage.is_repo_import
@property
def odb(self):
def cache(self):
odb_name = "repo" if self.is_in_repo else self.protocol
odb = getattr(self.repo.odb, odb_name)
odb = getattr(self.repo.cache, odb_name)
if self.use_cache and odb is None:
raise RemoteCacheRequiredError(self.fs.protocol, self.fs_path)
return odb
@property
def cache_path(self):
return self.odb.fs.unstrip_protocol(self.odb.oid_to_path(self.hash_info.value))
return self.cache.fs.unstrip_protocol(
self.cache.oid_to_path(self.hash_info.value)
)
def get_hash(self):
_, hash_info = self._get_hash_meta()
@@ -504,9 +526,9 @@ class Output:
def _get_hash_meta(self):
if self.use_cache:
odb = self.odb
odb = self.cache
else:
odb = self.repo.odb.local
odb = self.repo.cache.local
_, meta, obj = build(
odb,
self.fs_path,
@@ -548,26 +570,6 @@ class Output:
key = self.fs.path.parts(no_drive)[1:]
return workspace, key
def get_entry(self) -> "DataIndexEntry":
if self.files and not self.obj:
self.obj = self.get_obj()
entry = DataIndexEntry(
meta=self.meta,
obj=self.obj,
hash_info=self.hash_info,
)
if self.stage.is_import and not self.stage.is_repo_import:
dep = self.stage.deps[0]
entry.fs = dep.fs
entry.path = dep.fs_path
entry.meta = dep.meta
if not entry.obj:
if not dep.obj and dep.files:
dep.obj = dep.get_obj()
entry.obj = dep.obj
return entry
def changed_checksum(self):
return self.hash_info != self.get_hash()
@@ -580,7 +582,7 @@ class Output:
return True
try:
ocheck(self.odb, obj)
ocheck(self.cache, obj)
return False
except (FileNotFoundError, ObjectFormatError):
return True
@@ -669,7 +671,7 @@ class Output:
if self.use_cache:
_, self.meta, self.obj = build(
self.odb,
self.cache,
self.fs_path,
self.fs,
self.hash_name,
@@ -677,7 +679,7 @@ class Output:
)
else:
_, self.meta, self.obj = build(
self.repo.odb.local,
self.repo.cache.local,
self.fs_path,
self.fs,
self.hash_name,
@@ -692,7 +694,7 @@ class Output:
def set_exec(self) -> None:
if self.isfile() and self.meta.isexec:
self.odb.set_exec(self.fs_path)
self.cache.set_exec(self.fs_path)
def _checkout(self, *args, **kwargs) -> Optional[bool]:
from dvc_data.hashfile.checkout import CheckoutError as _CheckoutError
@@ -708,7 +710,7 @@ class Output:
except _CheckoutError as exc:
raise CheckoutError(exc.paths, {}) # noqa: B904
def commit(self, filter_info=None) -> None:
def commit(self, filter_info=None, relink=True) -> None:
if not self.exists:
raise self.DoesNotExistError(self)
@@ -718,11 +720,14 @@ class Output:
granular = (
self.is_dir_checksum and filter_info and filter_info != self.fs_path
)
# NOTE: trying to use hardlink during transfer only if we will be
# relinking later
hardlink = relink
if granular:
obj = self._commit_granular_dir(filter_info)
obj = self._commit_granular_dir(filter_info, hardlink)
else:
staging, _, obj = build(
self.odb,
self.cache,
filter_info or self.fs_path,
self.fs,
self.hash_name,
@@ -730,44 +735,46 @@ class Output:
)
otransfer(
staging,
self.odb,
self.cache,
{obj.hash_info},
shallow=False,
hardlink=True,
hardlink=hardlink,
)
self._checkout(
filter_info or self.fs_path,
self.fs,
obj,
self.odb,
relink=True,
state=self.repo.state,
prompt=prompt.confirm,
)
self.set_exec()
if relink:
self._checkout(
filter_info or self.fs_path,
self.fs,
obj,
self.cache,
relink=True,
state=self.repo.state,
prompt=prompt.confirm,
)
self.set_exec()
def _commit_granular_dir(self, filter_info) -> Optional["HashFile"]:
def _commit_granular_dir(self, filter_info, hardlink) -> Optional["HashFile"]:
prefix = self.fs.path.parts(self.fs.path.relpath(filter_info, self.fs_path))
staging, _, save_obj = build(
self.odb,
staging, _, obj = build(
self.cache,
self.fs_path,
self.fs,
self.hash_name,
ignore=self.dvcignore,
)
save_obj = cast(Tree, save_obj)
save_obj = cast(Tree, save_obj.filter(prefix))
checkout_obj = save_obj.get_obj(self.odb, prefix)
assert isinstance(obj, Tree)
save_obj = obj.filter(prefix)
assert isinstance(save_obj, Tree)
checkout_obj = save_obj.get_obj(self.cache, prefix)
otransfer(
staging,
self.odb,
self.cache,
{save_obj.hash_info} | {oid for _, _, oid in save_obj},
shallow=True,
hardlink=True,
hardlink=hardlink,
)
return checkout_obj
def dumpd(self, **kwargs): # noqa: C901
def dumpd(self, **kwargs): # noqa: C901, PLR0912
ret: Dict[str, Any] = {}
with_files = (
(not self.IS_DEPENDENCY or self.stage.is_import)
@@ -821,9 +828,10 @@ class Output:
if with_files:
obj = self.obj or self.get_obj()
if obj:
obj = cast("Tree", obj)
assert isinstance(obj, Tree)
ret[self.PARAM_FILES] = [
split_file_meta_from_cloud(f) for f in obj.as_list(with_meta=True)
split_file_meta_from_cloud(f)
for f in _serialize_tree_obj_to_files(obj)
]
return ret
@@ -858,7 +866,7 @@ class Output:
obj = tree
elif self.hash_info:
try:
obj = oload(self.odb, self.hash_info)
obj = oload(self.cache, self.hash_info)
except (FileNotFoundError, ObjectFormatError):
return None
else:
@@ -868,8 +876,8 @@ class Output:
fs_path = self.fs.path
if filter_info and filter_info != self.fs_path:
prefix = fs_path.relparts(filter_info, self.fs_path)
obj = cast(Tree, obj)
obj = obj.get_obj(self.odb, prefix)
assert isinstance(obj, Tree)
obj = obj.get_obj(self.cache, prefix)
return obj
@@ -905,7 +913,7 @@ class Output:
filter_info or self.fs_path,
self.fs,
obj,
self.odb,
self.cache,
force=force,
progress_callback=progress_callback,
relink=relink,
@@ -946,7 +954,7 @@ class Output:
self, source, odb=None, jobs=None, update=False, no_progress_bar=False
):
if odb is None:
odb = self.odb
odb = self.cache
cls, config, from_info = get_cloud_fs(self.repo, url=source)
from_fs = cls(**config)
@@ -998,25 +1006,26 @@ class Output:
def unprotect(self):
if self.exists:
self.odb.unprotect(self.fs_path)
self.cache.unprotect(self.fs_path)
def get_dir_cache(self, **kwargs):
if not self.is_dir_checksum:
raise DvcException("cannot get dir cache for file checksum")
obj = self.odb.get(self.hash_info.value)
obj = self.cache.get(self.hash_info.value)
try:
ocheck(self.odb, obj)
ocheck(self.cache, obj)
except FileNotFoundError:
if self.remote:
kwargs["remote"] = self.remote
self.repo.cloud.pull([obj.hash_info], **kwargs)
with suppress(Exception):
self.repo.cloud.pull([obj.hash_info], **kwargs)
if self.obj:
return self.obj
try:
self.obj = oload(self.odb, self.hash_info)
self.obj = oload(self.cache, self.hash_info)
except (FileNotFoundError, ObjectFormatError):
self.obj = None
@@ -1035,7 +1044,7 @@ class Output:
logger.debug("failed to pull cache for '%s'", self)
try:
ocheck(self.odb, self.odb.get(self.hash_info.value))
ocheck(self.cache, self.cache.get(self.hash_info.value))
except FileNotFoundError:
msg = (
"Missing cache for directory '{}'. "
@@ -1050,14 +1059,14 @@ class Output:
return None
obj = self.get_obj()
assert obj is None or isinstance(obj, Tree)
if filter_info and filter_info != self.fs_path:
assert obj
prefix = self.fs.path.parts(self.fs.path.relpath(filter_info, self.fs_path))
obj = cast(Tree, obj)
return obj.filter(prefix)
return cast(Tree, obj)
return obj
def get_used_objs( # noqa: C901
def get_used_objs( # noqa: C901, PLR0911
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
"""Return filtered set of used object IDs for this out."""
@@ -1099,7 +1108,7 @@ class Output:
else:
obj = self.get_obj(filter_info=kwargs.get("filter_info"))
if not obj:
obj = self.odb.get(self.hash_info.value)
obj = self.cache.get(self.hash_info.value)
if not obj:
return {}
@@ -1182,7 +1191,7 @@ class Output:
try:
merged = merge(
self.odb,
self.cache,
ancestor_info,
self.hash_info,
other.hash_info,
@@ -1191,12 +1200,12 @@ class Output:
except TreeMergeError as exc:
raise MergeError(str(exc)) from exc
self.odb.add(merged.path, merged.fs, merged.oid)
self.cache.add(merged.path, merged.fs, merged.oid)
self.hash_info = merged.hash_info
self.files = None
self.meta = Meta(
size=du(self.odb, merged),
size=du(self.cache, merged),
nfiles=len(merged),
)
+1 -1
View File
@@ -155,7 +155,7 @@ class Value(Node):
PRIMITIVES = (int, float, str, bytes, bool)
class Container(Node, ABC): # noqa: B024
class Container(Node, ABC):
meta: Meta
data: Union[list, dict]
_key_transform = staticmethod(identity)
+2 -3
View File
@@ -102,9 +102,8 @@ def _(obj: dict): # noqa: C901
if isinstance(v, bool):
if v:
result += f"--{k} "
else:
if config.get("bool", "store_true") == "boolean_optional":
result += f"--no-{k} "
elif config.get("bool", "store_true") == "boolean_optional":
result += f"--no-{k} "
elif isinstance(v, str):
result += f"--{k} {escape_str(v)} "
+1 -1
View File
@@ -19,7 +19,7 @@ class VersionEnum(str, enum.Enum):
return [v.value for v in cls]
class LOCKFILE_VERSION(VersionEnum):
class LOCKFILE_VERSION(VersionEnum): # noqa: N801
V1 = "1.0"
V2 = "2.0"
View File
-23
View File
@@ -1,23 +0,0 @@
from dvc.exceptions import DvcException
class ProcessNotTerminatedError(DvcException):
def __init__(self, name):
super().__init__(f"Managed process '{name}' has not been terminated.")
class ProcessNotFoundError(DvcException):
def __init__(self, name):
super().__init__(f"Managed process '{name}' does not exist.")
class TimeoutExpired(DvcException):
def __init__(self, cmd, timeout):
super().__init__(f"'{cmd}' did not complete before timeout '{timeout}'")
self.cmd = cmd
self.timeout = timeout
class UnsupportedSignalError(DvcException):
def __init__(self, sig):
super().__init__(f"Unsupported signal: {sig}")
-149
View File
@@ -1,149 +0,0 @@
"""Serverless process manager."""
import json
import logging
import os
import signal
import sys
from typing import Generator, List, Optional, Tuple, Union
from funcy.flow import reraise
from shortuuid import uuid
from .exceptions import ProcessNotTerminatedError, UnsupportedSignalError
from .process import ManagedProcess, ProcessInfo
logger = logging.getLogger(__name__)
class ProcessManager:
"""Manager for controlling background ManagedProcess(es).
Spawned process entries are kept in the manager directory until they
are explicitly removed (with remove() or cleanup()) so that return
value and log information can be accessed after a process has completed.
"""
def __init__(self, wdir: Optional[str] = None):
self.wdir = wdir or "."
def __iter__(self) -> Generator[str, None, None]:
if not os.path.exists(self.wdir):
return
yield from os.listdir(self.wdir)
def __getitem__(self, key: str) -> "ProcessInfo":
info_path = os.path.join(self.wdir, key, f"{key}.json")
try:
with open(info_path, encoding="utf-8") as fobj:
return ProcessInfo.from_dict(json.load(fobj))
except FileNotFoundError:
raise KeyError # noqa: B904
@reraise(FileNotFoundError, KeyError)
def __setitem__(self, key: str, value: "ProcessInfo"):
info_path = os.path.join(self.wdir, key, f"{key}.json")
with open(info_path, "w", encoding="utf-8") as fobj:
return json.dump(value.asdict(), fobj)
def __delitem__(self, key: str) -> None:
from dvc.utils.fs import remove
path = os.path.join(self.wdir, key)
if os.path.exists(path):
remove(path)
def get(self, key: str, default=None):
try:
return self[key]
except KeyError:
return default
def processes(self) -> Generator[Tuple[str, "ProcessInfo"], None, None]:
for name in self:
try:
yield name, self[name]
except KeyError:
continue
def spawn(self, args: Union[str, List[str]], name: Optional[str] = None):
"""Run the given command in the background."""
name = name or uuid()
pid = ManagedProcess.spawn(
args,
wdir=os.path.join(self.wdir, name),
name=name,
)
logger.debug(
"Spawned managed process '%s' (PID: '%d')",
name,
pid,
)
def send_signal(self, name: str, sig: int):
"""Send `signal` to the specified named process."""
process_info = self[name]
if sys.platform == "win32" and sig not in (
signal.SIGTERM,
signal.CTRL_C_EVENT, # pylint: disable=no-member
signal.CTRL_BREAK_EVENT, # pylint: disable=no-member
):
raise UnsupportedSignalError(sig)
def handle_closed_process():
logging.warning("Process %s had already aborted unexpectedly.", name)
process_info.returncode = -1
self[name] = process_info
if process_info.returncode is None:
try:
os.kill(process_info.pid, sig)
except ProcessLookupError:
handle_closed_process()
raise
except OSError as exc:
if getattr(exc, "winerror", None) == 87:
handle_closed_process()
raise ProcessLookupError from exc
raise
def terminate(self, name: str):
"""Terminate the specified named process."""
self.send_signal(name, signal.SIGTERM)
def kill(self, name: str):
"""Kill the specified named process."""
if sys.platform == "win32":
self.send_signal(name, signal.SIGTERM)
else:
self.send_signal(name, signal.SIGKILL) # pylint: disable=no-member
def remove(self, name: str, force: bool = False):
"""Remove the specified named process from this manager.
If the specified process is still running, it will be forcefully killed
if `force` is True`, otherwise an exception will be raised.
Raises:
ProcessNotTerminatedError if the specified process is still
running and was not forcefully killed.
"""
try:
process_info = self[name]
except KeyError:
return
if process_info.returncode is None and not force:
raise ProcessNotTerminatedError(name)
try:
self.kill(name)
except ProcessLookupError:
pass
del self[name]
def cleanup(self, force: bool = False):
"""Remove stale (terminated) processes from this manager."""
for name in self:
try:
self.remove(name, force)
except ProcessNotTerminatedError:
continue
-179
View File
@@ -1,179 +0,0 @@
import json
import logging
import os
import shlex
import subprocess
from contextlib import AbstractContextManager
from dataclasses import asdict, dataclass
from typing import BinaryIO, List, Optional, Union
from shortuuid import uuid
from dvc.utils.objects import cached_property
from .exceptions import TimeoutExpired
logger = logging.getLogger(__name__)
@dataclass
class ProcessInfo:
pid: int
stdin: Optional[str]
stdout: Optional[str]
stderr: Optional[str]
returncode: Optional[int]
@classmethod
def from_dict(cls, d):
return cls(**d)
def asdict(self):
return asdict(self)
class ManagedProcess(AbstractContextManager):
"""Run the specified command with redirected output.
stdout and stderr will both be redirected to <name>.out.
Interactive processes (requiring stdin input) are currently unsupported.
Parameters:
args: Command to be run.
wdir: If specified, redirected output files will be placed in `wdir`.
name: Name to use for this process, if not specified a UUID will be
generated instead.
"""
def __init__(
self,
args: Union[str, List[str]],
wdir: Optional[str] = None,
name: Optional[str] = None,
):
self.args: List[str] = (
shlex.split(args, posix=os.name == "posix")
if isinstance(args, str)
else list(args)
)
self.wdir = wdir
self.name = name or uuid()
self.returncode: Optional[int] = None
self._stdout: Optional[BinaryIO] = None
self._stderr: Optional[BinaryIO] = None
self._run()
def __exit__(self, exc_type, exc_value, traceback):
self.wait()
def _close_fds(self):
if self._stdout:
self._stdout.close()
self._stdout = None
if self._stderr:
self._stderr.close()
self._stderr = None
def _make_path(self, path: str) -> str:
return os.path.join(self.wdir, path) if self.wdir else path
@cached_property
def stdout_path(self) -> str:
return self._make_path(f"{self.name}.out")
@cached_property
def info_path(self) -> str:
return self._make_path(f"{self.name}.json")
@cached_property
def pidfile_path(self) -> str:
return self._make_path(f"{self.name}.pid")
@property
def info(self) -> "ProcessInfo":
return ProcessInfo(
pid=self.pid,
stdin=None,
stdout=self.stdout_path,
stderr=None,
returncode=self.returncode,
)
def _make_wdir(self):
if self.wdir:
os.makedirs(self.wdir, exist_ok=True)
def _dump(self):
self._make_wdir()
with open(self.info_path, "w", encoding="utf-8") as fobj:
json.dump(self.info.asdict(), fobj)
with open(self.pidfile_path, "w", encoding="utf-8") as fobj:
fobj.write(str(self.pid))
def _run(self) -> None:
self._make_wdir()
logger.debug(
"Appending output to '%s'",
self.stdout_path,
)
self._stdout = open(self.stdout_path, "ab") # noqa: SIM115
try:
self._proc = subprocess.Popen(
self.args,
stdin=subprocess.DEVNULL,
stdout=self._stdout,
stderr=subprocess.STDOUT,
close_fds=True,
shell=False,
)
self.pid: int = self._proc.pid
self._dump()
except Exception:
if self._proc is not None:
self._proc.kill()
self._close_fds()
raise
def wait(self, timeout: Optional[int] = None) -> Optional[int]:
"""Block until a process started with `run` has completed.
Raises:
TimeoutExpired if `timeout` was set and the process
did not terminate after `timeout` seconds.
"""
if self.returncode is not None:
return self.returncode
try:
self._proc.wait(timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise TimeoutExpired(exc.cmd, exc.timeout) from exc
self.returncode = self._proc.returncode
self._close_fds()
self._dump()
return self.returncode
@classmethod
def spawn(cls, *args, **kwargs) -> Optional[int]:
"""Spawn a ManagedProcess command in the background.
Returns: The spawned process PID.
"""
import multiprocessing as mp
proc = mp.Process(
target=cls._spawn,
args=args,
kwargs=kwargs,
daemon=True,
)
proc.start()
# Do not terminate the child daemon when the main process exits
# pylint: disable=protected-access
mp.process._children.discard(proc) # type: ignore[attr-defined]
return proc.pid
@classmethod
def _spawn(cls, *args, **kwargs):
with cls(*args, **kwargs):
pass
+1 -1
View File
@@ -36,7 +36,7 @@ class Tqdm(tqdm):
"miniters": 1,
}
def __init__(
def __init__( # noqa: PLR0913
self,
iterable=None,
disable=None,
+1 -3
View File
@@ -29,10 +29,8 @@ def _group_by_rev(datapoints):
def to_json(renderer, split: bool = False) -> List[Dict]:
from copy import deepcopy
if renderer.TYPE == "vega":
grouped = _group_by_rev(deepcopy(renderer.datapoints))
grouped = _group_by_rev(renderer.datapoints)
if split:
content = renderer.get_filled_template(skip_anchors=["data"])
else:
+18 -5
View File
@@ -1,4 +1,4 @@
from copy import deepcopy
import os
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from funcy import first, last
@@ -194,7 +194,7 @@ class VegaConverter(Converter):
return first(fields)
return "x"
def flat_datapoints(self, revision): # noqa: C901
def flat_datapoints(self, revision): # noqa: C901, PLR0912
file2datapoints, properties = self.convert()
props_update = {}
@@ -222,7 +222,13 @@ class VegaConverter(Converter):
)
all_datapoints = []
all_y_fields = {y_field for _, y_field in ys}
if ys:
all_y_files, all_y_fields = list(zip(*ys))
all_y_fields = set(all_y_fields)
all_y_files = set(all_y_files)
else:
all_y_files = set()
all_y_fields = set()
# override to unified y field name if there are different y fields
if len(all_y_fields) > 1:
@@ -230,10 +236,16 @@ class VegaConverter(Converter):
else:
props_update["y"] = first(all_y_fields)
# get common prefix to drop from file names
if len(all_y_files) > 1:
common_prefix_len = len(os.path.commonpath(all_y_files))
else:
common_prefix_len = 0
for i, (y_file, y_field) in enumerate(ys):
if num_xs > 1:
x_file, x_field = xs[i]
datapoints = deepcopy(file2datapoints.get(y_file, []))
datapoints = [d.copy() for d in file2datapoints.get(y_file, [])]
if props_update.get("y", None) == "dvc_inferred_y_value":
_update_from_field(
@@ -259,12 +271,13 @@ class VegaConverter(Converter):
"They have to have same length."
)
y_file_short = y_file[common_prefix_len:].strip("/\\")
_update_all(
datapoints,
update_dict={
VERSION_FIELD: {
"revision": revision,
FILENAME_FIELD: y_file,
FILENAME_FIELD: y_file_short,
"field": y_field,
}
},
+10 -6
View File
@@ -1,11 +1,12 @@
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Optional
import dpath
import dpath.options
import dpath.util
from funcy import last
from dvc.repo.plots import infer_data_sources
from dvc.repo.plots import _normpath, infer_data_sources
from dvc.utils.plots import get_plot_id
from .convert import _get_converter
@@ -43,17 +44,20 @@ class PlotsData:
def get_definition_data(self, target_files, rev):
result = {}
for file in target_files:
for definition_file in target_files:
if os.name == "nt":
source_file = _normpath(definition_file).replace("\\", "/")
else:
source_file = definition_file
file_content = (
self.data.get(rev, {})
.get("sources", {})
.get("data", {})
.get(file, {})
.get(source_file, {})
.get("data", {})
)
if file_content:
result[file] = file_content
result[definition_file] = file_content
return result
+107 -34
View File
@@ -3,7 +3,16 @@ import os
from collections import defaultdict
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Tuple, Union, cast
from typing import (
TYPE_CHECKING,
Callable,
ContextManager,
Iterable,
List,
Optional,
Tuple,
Union,
)
from dvc.exceptions import FileMissingError
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
@@ -14,7 +23,7 @@ from dvc.utils.fs import path_isin
from dvc.utils.objects import cached_property
if TYPE_CHECKING:
from dvc.fs import FileSystem, GitFileSystem
from dvc.fs import FileSystem
from dvc.fs.data import DataFileSystem
from dvc.fs.dvc import DVCFileSystem
from dvc.lock import LockBase
@@ -23,6 +32,7 @@ if TYPE_CHECKING:
from dvc.stage import Stage
from dvc.types import DictStrAny
from dvc_data.hashfile.state import StateBase
from dvc_data.index import DataIndex
from .experiments import Experiments
from .index import Index
@@ -99,17 +109,15 @@ class Repo:
fs: Optional["FileSystem"] = None,
uninitialized: bool = False,
scm: Optional[Union["Git", "NoSCM"]] = None,
) -> Tuple[str, Optional[str], Optional[str]]:
) -> Tuple[str, Optional[str]]:
from dvc.fs import localfs
from dvc.scm import SCM, SCMError
dvc_dir: Optional[str] = None
tmp_dir: Optional[str] = None
try:
root_dir = self.find_root(root_dir, fs)
fs = fs or localfs
dvc_dir = fs.path.join(root_dir, self.DVC_DIR)
tmp_dir = fs.path.join(dvc_dir, "tmp")
except NotDvcRepoError:
if not uninitialized:
raise
@@ -124,9 +132,11 @@ class Repo:
root_dir = scm.root_dir
assert root_dir
return root_dir, dvc_dir, tmp_dir
return root_dir, dvc_dir
def _get_database_dir(self, db_name: str) -> Optional[str]:
from dvc.fs import localfs
# NOTE: by default, store SQLite-based remote indexes and state's
# `links` and `md5s` caches in the repository itself to avoid any
# possible state corruption in 'shared cache dir' scenario, but allow
@@ -139,18 +149,24 @@ class Repo:
import hashlib
root_dir_hash = hashlib.sha224(self.root_dir.encode("utf-8")).hexdigest()
if self.local_dvc_dir:
fs: "FileSystem" = localfs
local_root = fs.path.parent(self.local_dvc_dir)
else:
fs = self.fs
local_root = self.root_dir
root_dir_hash = hashlib.sha224(local_root.encode("utf-8")).hexdigest()
db_dir = self.fs.path.join(
db_dir = fs.path.join(
base_db_dir,
self.DVC_DIR,
f"{self.fs.path.name(self.root_dir)}-{root_dir_hash[0:7]}",
f"{fs.path.name(local_root)}-{root_dir_hash[0:7]}",
)
self.fs.makedirs(db_dir, exist_ok=True)
fs.makedirs(db_dir, exist_ok=True)
return db_dir
def __init__(
def __init__( # noqa: PLR0915
self,
root_dir: Optional[str] = None,
fs: Optional["FileSystem"] = None,
@@ -162,11 +178,11 @@ class Repo:
repo_factory: Optional[Callable] = None,
scm: Optional[Union["Git", "NoSCM"]] = None,
):
from dvc.cachemgr import CacheManager
from dvc.config import Config
from dvc.data_cloud import DataCloud
from dvc.fs import GitFileSystem, LocalFileSystem, localfs
from dvc.lock import LockNoop, make_lock
from dvc.odbmgr import ODBManager
from dvc.repo.metrics import Metrics
from dvc.repo.params import Params
from dvc.repo.plots import Plots
@@ -179,6 +195,7 @@ class Repo:
self._fs_conf = {"repo_factory": repo_factory}
self._fs = fs or localfs
self._scm = scm
self._data_index = None
if rev and not fs:
self._scm = scm = SCM(root_dir or os.curdir)
@@ -187,8 +204,10 @@ class Repo:
self.root_dir: str
self.dvc_dir: Optional[str]
self.tmp_dir: Optional[str]
self.root_dir, self.dvc_dir, self.tmp_dir = self._get_repo_dirs(
(
self.root_dir,
self.dvc_dir,
) = self._get_repo_dirs(
root_dir=root_dir,
fs=self.fs,
uninitialized=uninitialized,
@@ -205,17 +224,17 @@ class Repo:
self.stage: "StageLoad" = StageLoad(self)
self.lock: "LockBase"
self.odb: ODBManager
self.cache: CacheManager
self.state: "StateBase"
if isinstance(self.fs, GitFileSystem) or not self.dvc_dir:
self.lock = LockNoop()
self.state = StateNoop()
self.odb = ODBManager(self)
self.tmp_dir = None
self.cache = CacheManager(self)
else:
self.fs.makedirs(cast(str, self.tmp_dir), exist_ok=True)
if isinstance(self.fs, LocalFileSystem):
assert self.tmp_dir
self.fs.makedirs(self.tmp_dir, exist_ok=True)
self.lock = make_lock(
self.fs.path.join(self.tmp_dir, "lock"),
tmp_dir=self.tmp_dir,
@@ -228,7 +247,7 @@ class Repo:
self.lock = LockNoop()
self.state = StateNoop()
self.odb = ODBManager(self)
self.cache = CacheManager(self)
self.stage_cache = StageCache(self)
@@ -246,6 +265,41 @@ class Repo:
def __str__(self):
return self.url or self.root_dir
@cached_property
def local_dvc_dir(self):
from dvc.fs import GitFileSystem, LocalFileSystem
if not self.dvc_dir:
return None
if isinstance(self.fs, LocalFileSystem):
return self.dvc_dir
if not isinstance(self.fs, GitFileSystem):
return None
relparts = ()
if self.root_dir != "/":
# subrepo
relparts = self.fs.path.relparts(self.root_dir, "/")
dvc_dir = os.path.join(
self.scm.root_dir,
*relparts,
self.DVC_DIR,
)
if os.path.exists(dvc_dir):
return dvc_dir
return None
@cached_property
def tmp_dir(self):
if self.local_dvc_dir is None:
return None
return os.path.join(self.local_dvc_dir, "tmp")
@cached_property
def index(self) -> "Index":
from dvc.repo.index import Index
@@ -304,7 +358,7 @@ class Repo:
return DvcIgnoreFilter(self.fs, self.root_dir)
def get_rev(self):
from dvc.fs import LocalFileSystem
from dvc.fs import GitFileSystem, LocalFileSystem
assert self.scm
if isinstance(self.fs, LocalFileSystem):
@@ -312,7 +366,8 @@ class Repo:
with map_scm_exception():
return self.scm.get_rev()
return cast("GitFileSystem", self.fs).rev
assert isinstance(self.fs, GitFileSystem)
return self.fs.rev
@cached_property
def experiments(self) -> "Experiments":
@@ -341,6 +396,21 @@ class Repo:
# fs.
self._reset()
@property
def data_index(self) -> Optional["DataIndex"]:
from dvc_data.index import DataIndex
if not self.index_db_dir:
return None
if self._data_index is None:
index_dir = os.path.join(self.index_db_dir, "index", "data")
os.makedirs(index_dir, exist_ok=True)
self._data_index = DataIndex.open(os.path.join(index_dir, "db.db"))
return self._data_index
def __repr__(self):
return f"{self.__class__.__name__}: '{self.root_dir}'"
@@ -385,14 +455,14 @@ class Repo:
return init(root_dir=root_dir, no_scm=no_scm, force=force, subdir=subdir)
def unprotect(self, target):
return self.odb.repo.unprotect(target)
return self.cache.repo.unprotect(target)
def _ignore(self):
flist = [self.config.files["local"]]
if tmp_dir := self.tmp_dir:
flist.append(tmp_dir)
if path_isin(self.odb.repo.path, self.root_dir):
flist.append(self.odb.repo.path)
if path_isin(self.cache.repo.path, self.root_dir):
flist.append(self.cache.repo.path)
for file in flist:
self.scm_context.ignore(file)
@@ -402,7 +472,12 @@ class Repo:
return brancher(self, *args, **kwargs)
def used_objs(
def switch(self, rev: str) -> ContextManager[str]:
from dvc.repo.brancher import switch
return switch(self, rev)
def used_objs( # noqa: PLR0913
self,
targets=None,
all_branches=False,
@@ -553,21 +628,19 @@ class Repo:
def close(self):
self.scm.close()
self.state.close()
if self._data_index is not None:
self._data_index.close()
def _reset_cached_indecies(self):
def _reset(self):
self.scm._reset() # pylint: disable=protected-access
self.state.close()
self.__dict__.pop("index", None)
self.__dict__.pop("dvcignore", None)
self.__dict__.pop("dvcfs", None)
self.__dict__.pop("datafs", None)
def _reset(self):
self.state.close()
self.scm._reset() # pylint: disable=protected-access
self._reset_cached_indecies()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._reset()
self.scm.close()
self.close()
+1 -1
View File
@@ -148,7 +148,7 @@ VALIDATORS = (
@validate(*VALIDATORS)
@locked
@scm_context
def add( # noqa: C901
def add(
repo: "Repo",
targets: "TargetType",
recursive: bool = False,
+72 -14
View File
@@ -1,7 +1,17 @@
from typing import Optional
import logging
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator, Optional, Tuple
from scmrepo.git import Git
from dvc.exceptions import NotDvcRepoError
from dvc.scm import iter_revs
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
def brancher( # noqa: E302
self,
@@ -60,6 +70,7 @@ def brancher( # noqa: E302
scm = self.scm
logger.trace("switching fs to workspace") # type: ignore[attr-defined]
self.fs = LocalFileSystem(url=self.root_dir)
yield "workspace"
@@ -79,21 +90,68 @@ def brancher( # noqa: E302
)
try:
from dvc.fs import GitFileSystem
for sha, names in found_revs.items():
self._reset_cached_indecies() # pylint: disable=W0212
self.fs = GitFileSystem(scm=scm, rev=sha)
self.root_dir = self.fs.path.join("/", *repo_root_parts)
if cwd_parts:
cwd = self.fs.path.join("/", *cwd_parts) # type: ignore[unreachable]
self.fs.path.chdir(cwd)
# ignore revs that don't contain repo root
# (i.e. revs from before a subdir=True repo was init'ed)
if self.fs.exists(self.root_dir):
try:
_switch_fs(self, sha, repo_root_parts, cwd_parts)
yield sha if sha_only else ",".join(names)
except NotDvcRepoError:
# ignore revs that don't contain repo root
# (i.e. revs from before a subdir=True repo was init'ed)
pass
finally:
self.fs = saved_fs
self.root_dir = saved_root
def _switch_fs(
repo: "Repo",
rev: str,
repo_root_parts: Tuple[str, ...],
cwd_parts: Tuple[str, ...],
):
from dvc.fs import GitFileSystem, LocalFileSystem
if rev == "workspace":
logger.trace("switching fs to workspace") # type: ignore[attr-defined]
repo.fs = LocalFileSystem(url=repo.root_dir)
return
logger.trace("switching fs to revision %s", rev[:7]) # type: ignore[attr-defined]
assert isinstance(repo.scm, Git)
fs = GitFileSystem(scm=repo.scm, rev=rev)
root_dir = repo.fs.path.join("/", *repo_root_parts)
if not fs.exists(root_dir):
raise NotDvcRepoError(f"Commit '{rev[:7]}' does not contain a DVC repo")
repo.fs = fs
repo.root_dir = root_dir
if cwd_parts:
cwd = repo.fs.path.join("/", *cwd_parts)
repo.fs.path.chdir(cwd)
@contextmanager
def switch(repo: "Repo", rev: str) -> Iterator[str]:
"""Switch to a specific revision."""
from dvc.scm import resolve_rev
if rev != "workspace":
rev = resolve_rev(repo.scm, rev)
repo_root_parts = ()
if repo.fs.path.isin(repo.root_dir, repo.scm.root_dir):
repo_root_parts = repo.fs.path.relparts(repo.root_dir, repo.scm.root_dir)
cwd_parts = ()
if repo.fs.path.isin(repo.fs.path.getcwd(), repo.scm.root_dir):
cwd_parts = repo.fs.path.relparts(repo.fs.path.getcwd(), repo.scm.root_dir)
saved_fs = repo.fs
saved_root = repo.root_dir
try:
_switch_fs(repo, rev, repo_root_parts, cwd_parts)
yield rev
finally:
repo.fs = saved_fs
repo.root_dir = saved_root
+6 -1
View File
@@ -43,6 +43,7 @@ def commit(
force=False,
allow_missing=False,
data_only=False,
relink=True,
):
stages_info = [
info
@@ -60,6 +61,10 @@ def commit(
if any(changes):
prompt_to_commit(stage, changes, force=force)
stage.save(allow_missing=allow_missing)
stage.commit(filter_info=stage_info.filter_info, allow_missing=allow_missing)
stage.commit(
filter_info=stage_info.filter_info,
allow_missing=allow_missing,
relink=relink,
)
stage.dump(update_pipeline=False)
return [s.stage for s in stages_info]
+52 -22
View File
@@ -13,17 +13,20 @@ from typing import (
Union,
)
from dvc.ui import ui
if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.scm import Git, NoSCM
from dvc_data.index import DataIndex
from dvc_data.index.diff import Change
def posixpath_to_os_path(path: str) -> str:
return path.replace(posixpath.sep, os.path.sep)
def _adapt_typ(typ):
def _adapt_typ(typ: str) -> str:
from dvc_data.index.diff import ADD, DELETE, MODIFY
if typ == MODIFY:
@@ -38,7 +41,7 @@ def _adapt_typ(typ):
return typ
def _adapt_path(change):
def _adapt_path(change: "Change") -> str:
isdir = False
if change.new and change.new.meta:
isdir = change.new.meta.isdir
@@ -54,9 +57,12 @@ def _diff(
old: "DataIndex",
new: "DataIndex",
*,
granular: Optional[bool] = False,
with_missing: Optional[bool] = False,
granular: bool = False,
not_in_cache: bool = False,
not_in_remote: bool = False,
remote_refresh: bool = False,
) -> Dict[str, List[str]]:
from dvc_data.index import StorageError
from dvc_data.index.diff import UNCHANGED, UNKNOWN, diff
ret: Dict[str, List[str]] = {}
@@ -91,14 +97,27 @@ def _diff(
continue
if (
with_missing
not_in_cache
and change.old
and change.old.hash_info
and not old.odb_map[change.key].exists(change.old.hash_info.value)
and not old.storage_map.cache_exists(change.old)
):
# NOTE: emulating previous behaviour
_add_change("not_in_cache", change)
try:
if (
not_in_remote
and change.old
and change.old.hash_info
and not old.storage_map.remote_exists(
change.old, refresh=remote_refresh
)
):
_add_change("not_in_remote", change)
except StorageError:
pass
_add_change(change.typ, change)
return ret
@@ -143,14 +162,19 @@ def _git_info(scm: Union["Git", "NoSCM"], untracked_files: str = "all") -> GitIn
def _diff_index_to_wtree(repo: "Repo", **kwargs: Any) -> Dict[str, List[str]]:
from .index import build_data_index
workspace = build_data_index(repo.index, repo.root_dir, repo.fs, compute_hash=True)
with ui.status("Building workspace index"):
workspace = build_data_index(
repo.index, repo.root_dir, repo.fs, compute_hash=True
)
return _diff(
repo.index.data["repo"],
workspace,
with_missing=True,
**kwargs,
)
with ui.status("Calculating diff between index/workspace"):
return _diff(
repo.index.data["repo"],
workspace,
not_in_cache=True,
not_in_remote=True,
**kwargs,
)
def _diff_head_to_index(
@@ -158,17 +182,16 @@ def _diff_head_to_index(
) -> Dict[str, List[str]]:
index = repo.index.data["repo"]
for rev in repo.brancher(revs=[head]):
if rev == "workspace":
continue
with repo.switch(head):
head_index = repo.index.data["repo"]
return _diff(head_index, index, **kwargs)
with ui.status("Calculating diff between head/index"):
return _diff(head_index, index, **kwargs)
class Status(TypedDict):
not_in_cache: List[str]
not_in_remote: List[str]
committed: Dict[str, List[str]]
uncommitted: Dict[str, List[str]]
untracked: List[str]
@@ -195,12 +218,18 @@ def _transform_git_paths_to_dvc(repo: "Repo", files: Iterable[str]) -> List[str]
return [repo.fs.path.relpath(file, start) for file in files]
def status(repo: "Repo", untracked_files: str = "no", **kwargs: Any) -> Status:
def status(
repo: "Repo",
untracked_files: str = "no",
**kwargs: Any,
) -> Status:
from dvc.scm import NoSCMError, SCMError
head = kwargs.pop("head", "HEAD")
uncommitted_diff = _diff_index_to_wtree(repo, **kwargs)
not_in_cache = uncommitted_diff.pop("not_in_cache", [])
uncommitted_diff = _diff_index_to_wtree(
repo,
**kwargs,
)
unchanged = set(uncommitted_diff.pop("unchanged", []))
try:
@@ -215,7 +244,8 @@ def status(repo: "Repo", untracked_files: str = "no", **kwargs: Any) -> Status:
untracked = _transform_git_paths_to_dvc(repo, untracked)
# order matters here
return Status(
not_in_cache=not_in_cache,
not_in_cache=uncommitted_diff.pop("not_in_cache", []),
not_in_remote=uncommitted_diff.pop("not_in_remote", []),
committed=committed_diff,
uncommitted=uncommitted_diff,
untracked=untracked,
+119 -228
View File
@@ -1,39 +1,103 @@
import logging
import os
from collections import defaultdict
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
Iterator,
List,
Optional,
Set,
Tuple,
cast,
)
from typing import Dict, List, Optional
from dvc.exceptions import PathMissingError
from dvc.repo import locked
from dvc.utils.collections import ensure_list
if TYPE_CHECKING:
from dvc.fs import DVCFileSystem, FileSystem
from dvc.output import Output
from dvc.repo import Repo
from dvc_data.hashfile.obj import HashFile
from dvc_data.hashfile.tree import Tree
from dvc.ui import ui
logger = logging.getLogger(__name__)
def _path(entry):
if entry and entry.meta and entry.meta.isdir:
return os.path.join(*entry.key, "")
return os.path.join(*entry.key)
def _hash(entry):
if entry and entry.hash_info:
return entry.hash_info.value
return None
def _diff(old, new, with_missing=False):
from dvc_data.index.diff import ADD, DELETE, MODIFY, RENAME
from dvc_data.index.diff import diff as idiff
ret: "Dict[str, List[Dict]]" = {
"added": [],
"deleted": [],
"modified": [],
"renamed": [],
"not in cache": [],
}
for change in idiff(
old,
new,
with_renames=True,
hash_only=True,
):
if change.typ == ADD:
ret["added"].append(
{
"path": _path(change.new),
"hash": _hash(change.new),
}
)
elif change.typ == DELETE:
ret["deleted"].append(
{
"path": _path(change.old),
"hash": _hash(change.old),
}
)
elif change.typ == MODIFY:
ret["modified"].append(
{
"path": _path(change.old),
"hash": {
"old": _hash(change.old),
"new": _hash(change.new),
},
}
)
elif change.typ == RENAME:
ret["renamed"].append(
{
"path": {
"old": _path(change.old),
"new": _path(change.new),
},
"hash": _hash(change.old),
}
)
if (
with_missing
and change.old
and change.old.hash_info
and not old.storage_map.cache_exists(change.old)
):
ret["not in cache"].append(
{
"path": _path(change.old),
"hash": _hash(change.old),
}
)
return ret if any(ret.values()) else {}
@locked
def diff(
self,
a_rev: str = "HEAD",
b_rev: Optional[str] = None,
targets: Optional[List[str]] = None,
recursive: bool = False,
):
"""
By default, it compares the workspace with the last commit's fs.
@@ -42,231 +106,58 @@ def diff(
the concept of `index`, but it keeps the same interface, thus,
`dvc diff` would be the same as `dvc diff HEAD`.
"""
if self.scm.no_commits:
return {}
from dvc.fs.dvc import DVCFileSystem
indexes = {}
missing_targets = defaultdict(set)
with_missing = False
if not b_rev:
b_rev = "workspace"
with_missing = True
dvcfs = DVCFileSystem(repo=self)
targets = [dvcfs.from_os_path(target) for target in ensure_list(targets)]
b_rev = b_rev if b_rev else "workspace"
results = {}
missing_targets = {}
for rev in self.brancher(revs=[a_rev, b_rev]):
if rev == "workspace" and rev != b_rev:
if rev == "workspace" and b_rev != "workspace":
# brancher always returns workspace, but we only need to compute
# workspace paths/checksums if b_rev was None
continue
targets_paths = None
if targets:
# convert targets to paths, and capture any missing targets
targets_paths, missing_targets[rev] = _targets_to_paths(dvcfs, targets)
def onerror(target, _exc):
# pylint: disable-next=cell-var-from-loop
missing_targets[rev].add(target) # noqa: B023
results[rev] = _paths_checksums(self, targets_paths)
if targets:
# check for overlapping missing targets between a_rev and b_rev
for target in set(missing_targets[a_rev]) & set(missing_targets[b_rev]):
raise PathMissingError(target, self)
old = results[a_rev]
new = results[b_rev]
# Compare paths between the old and new fs.
# set() efficiently converts dict keys to a set
added = sorted(set(new) - set(old))
deleted_or_missing = set(old) - set(new)
if b_rev == "workspace":
# missing status is only applicable when diffing local workspace
# against a commit
missing = sorted(_filter_missing(dvcfs, deleted_or_missing))
else:
missing = []
deleted = sorted(deleted_or_missing - set(missing))
modified = sorted(set(old) & set(new))
# Cases when file was changed and renamed are resulted
# in having deleted and added record
# To cover such cases we need to change hashing function
# to produce rolling/chunking hash
renamed = _calculate_renamed(new, old, added, deleted)
for renamed_item in renamed:
added.remove(renamed_item["path"]["new"])
deleted.remove(renamed_item["path"]["old"])
ret = {
"added": [{"path": path, "hash": new[path]} for path in added],
"deleted": [{"path": path, "hash": old[path]} for path in deleted],
"modified": [
{"path": path, "hash": {"old": old[path], "new": new[path]}}
for path in modified
if old[path] != new[path]
],
"renamed": renamed,
"not in cache": [{"path": path, "hash": old[path]} for path in missing],
}
return ret if any(ret.values()) else {}
def _paths_checksums(
repo: "Repo", targets: Optional[List[str]]
) -> Dict[str, Optional[str]]:
"""
A dictionary of checksums addressed by relpaths collected from
the current fs outputs.
To help distinguish between a directory and a file output,
the former one will come with a trailing slash in the path:
directory: "data/"
file: "data"
"""
return dict(_output_paths(repo, targets))
def _output_paths( # noqa: C901
repo: "Repo", targets: Optional[List[str]]
) -> Iterable[Tuple[str, Optional[str]]]:
from dvc.fs import LocalFileSystem
from dvc_data.hashfile.build import build
on_working_fs = isinstance(repo.fs, LocalFileSystem)
def _exists(output: "Output") -> bool:
if on_working_fs:
return output.exists
return True
def _to_path(output: "Output") -> str:
relparts = output.fs.path.relparts(output.fs_path)
base = os.path.join(*relparts)
if output.is_dir_checksum:
return os.path.join(base, "")
return base
for output in repo.index.outs:
if not _exists(output):
continue
yield_output = targets is None or any(
output.fs.path.isin_or_eq(output.fs_path, target) for target in targets
view = self.index.targets_view(
targets,
onerror=onerror,
recursive=recursive,
)
obj: Optional["HashFile"]
if on_working_fs:
_, _, obj = build(
repo.odb.repo,
output.fs_path,
repo.odb.repo.fs,
"md5",
dry_run=True,
ignore=output.dvcignore,
)
hash_info = obj.hash_info
else:
hash_info = output.hash_info
obj = output.get_obj()
if rev == "workspace":
from .index import build_data_index
if yield_output:
yield _to_path(output), hash_info.value
if not obj:
continue
if output.is_dir_checksum and (
yield_output
or (
isinstance(targets, list)
and any(
output.fs.path.isin(target, output.fs_path) for target in targets
with ui.status("Building workspace index"):
data = build_data_index(
view,
self.root_dir,
self.fs,
compute_hash=True,
)
)
):
obj = cast("Tree", obj)
yield from _dir_output_paths(output.fs, output.fs_path, obj, targets)
def _dir_output_paths(
fs: "FileSystem",
fs_path: str,
obj: "Tree",
targets: Optional[Iterable[str]] = None,
) -> Iterator[Tuple[str, str]]:
base = os.path.join(*fs.path.relparts(fs_path))
for key, _, oid in obj:
fname = fs.path.join(fs_path, *key)
if targets is None or any(
fs.path.isin_or_eq(fname, target) for target in targets
):
# pylint: disable=no-member
yield os.path.join(base, *key), oid.value
def _filter_missing(dvcfs: "DVCFileSystem", paths: Set[str]) -> Iterator[str]:
for path in paths:
fs_path = dvcfs.from_os_path(path)
try:
info = dvcfs.info(fs_path)
dvc_info = info.get("dvc_info", {})
entry = dvc_info.get("entry")
if (
entry
and info["type"] == "directory"
and not dvcfs.repo.odb.local.exists(entry.hash_info.value)
):
yield path
except FileNotFoundError:
pass
def _targets_to_paths(
dvcfs: "DVCFileSystem", targets: List[str]
) -> Tuple[List[str], List[str]]:
paths: List[str] = []
missing: List[str] = []
if not targets:
return paths, missing
for target in targets:
if dvcfs.exists(target):
paths.append(dvcfs.repo.fs.path.abspath(target))
else:
missing.append(target)
data = view.data["repo"]
return paths, missing
assert rev not in indexes
indexes[rev] = data
if targets:
old_missing = missing_targets.get(a_rev, set())
new_missing = missing_targets.get(b_rev, set())
def _calculate_renamed(
new: Dict[str, Any],
old: Dict[str, Any],
added: Iterable[str],
deleted: Iterable[str],
) -> List[Dict[str, Dict[str, str]]]:
old_inverted: Dict[str, List[str]] = defaultdict(list)
# It is needed to be dict of lists to cover cases
# when repo has paths with same hash
for path, path_hash in old.items():
old_inverted[path_hash].append(path)
# check for overlapping missing targets between a_rev and b_rev
for target in old_missing & new_missing:
raise PathMissingError(target, self)
renamed = []
for path in added:
path_hash = new[path]
old_paths = old_inverted[path_hash]
try:
iterator = enumerate(old_paths)
index = next(idx for idx, path in iterator if path in deleted)
except StopIteration:
continue
old = indexes[a_rev]
new = indexes[b_rev]
old_path = old_paths.pop(index)
renamed.append({"path": {"old": old_path, "new": path}, "hash": path_hash})
return renamed
with ui.status("Calculating diff"):
return _diff(old, new, with_missing=with_missing)
+17 -11
View File
@@ -11,6 +11,7 @@ from dvc.ui import ui
from dvc.utils import relpath
from dvc.utils.objects import cached_property
from .cache import ExpCache
from .exceptions import (
BaselineMismatchError,
ExperimentExistsError,
@@ -22,6 +23,7 @@ from .queue.celery import LocalCeleryQueue
from .queue.tempdir import TempDirQueue
from .queue.workspace import WorkspaceQueue
from .refs import (
APPLY_STASH,
CELERY_FAILED_STASH,
CELERY_STASH,
EXEC_APPLY,
@@ -31,6 +33,7 @@ from .refs import (
WORKSPACE_STASH,
ExpRefInfo,
)
from .stash import ApplyStash
from .utils import check_ref_format, exp_refs_by_rev, unlocked_repo
if TYPE_CHECKING:
@@ -86,6 +89,14 @@ class Experiments:
def celery_queue(self) -> LocalCeleryQueue:
return LocalCeleryQueue(self.repo, CELERY_STASH, CELERY_FAILED_STASH)
@cached_property
def apply_stash(self) -> ApplyStash:
return ApplyStash(self.scm, APPLY_STASH)
@cached_property
def cache(self) -> ExpCache:
return ExpCache(self.repo)
@property
def stash_revs(self) -> Dict[str, "ExpStashEntry"]:
revs = {}
@@ -96,19 +107,9 @@ class Experiments:
def reproduce_one(
self,
tmp_dir: bool = False,
machine: Optional[str] = None,
**kwargs,
):
"""Reproduce and checkout a single (standalone) experiment."""
if not (tmp_dir or machine):
staged, _, _ = self.scm.status(untracked_files="no")
if staged:
logger.warning(
"Your workspace contains staged Git changes which will be "
"unstaged before running this experiment."
)
self.scm.reset()
exp_queue: "BaseStashQueue" = (
self.tempdir_queue if tmp_dir else self.workspace_queue
)
@@ -417,7 +418,7 @@ class Experiments:
try:
name = ExpRefInfo.from_ref(ref).name
except InvalidExpRefError:
pass # noqa: S110
pass
if not name:
if rev in self.stash_revs:
name = self.stash_revs[rev].name
@@ -493,3 +494,8 @@ class Experiments:
from dvc.repo.experiments.remove import remove
return remove(self.repo, *args, **kwargs)
def clean(self, *args, **kwargs):
from dvc.repo.experiments.clean import clean
return clean(self.repo, *args, **kwargs)
+16 -70
View File
@@ -1,14 +1,14 @@
import logging
import os
from contextlib import contextmanager
from typing import TYPE_CHECKING, Optional
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm import Git
from dvc.ui import ui
from dvc.utils.fs import remove
from .exceptions import ApplyConflictError, BaselineMismatchError, InvalidExpRevError
from .exceptions import BaselineMismatchError, InvalidExpRevError
from .executor.base import BaseExecutor
from .refs import EXEC_APPLY
@@ -21,11 +21,9 @@ logger = logging.getLogger(__name__)
@locked
@scm_context
def apply(repo: "Repo", rev: str, force: bool = True, **kwargs): # noqa: C901
from scmrepo.exceptions import SCMError as _SCMError
def apply(repo: "Repo", rev: str, **kwargs): # noqa: C901
from dvc.repo.checkout import checkout as dvc_checkout
from dvc.scm import GitMergeError, RevError, resolve_rev
from dvc.scm import RevError, resolve_rev
exps: "Experiments" = repo.experiments
@@ -34,7 +32,6 @@ def apply(repo: "Repo", rev: str, force: bool = True, **kwargs): # noqa: C901
assert isinstance(repo.scm, Git)
try:
exp_rev = resolve_rev(repo.scm, rev)
exps.check_baseline(exp_rev)
except RevError as exc:
(
exp_ref_info,
@@ -42,14 +39,9 @@ def apply(repo: "Repo", rev: str, force: bool = True, **kwargs): # noqa: C901
) = exps.celery_queue.get_ref_and_entry_by_names(
rev
)[rev]
baseline_sha = repo.scm.get_rev()
if exp_ref_info:
if baseline_sha != exp_ref_info.baseline_sha:
raise InvalidExpRevError(rev) # noqa: B904
exp_rev = repo.scm.get_ref(str(exp_ref_info))
elif queue_entry:
if queue_entry.baseline_rev != baseline_sha:
raise InvalidExpRevError(rev)
exp_rev = queue_entry.stash_rev
is_stash = True
else:
@@ -57,68 +49,22 @@ def apply(repo: "Repo", rev: str, force: bool = True, **kwargs): # noqa: C901
except BaselineMismatchError as exc:
raise InvalidExpRevError(rev) from exc
# NOTE: we don't use scmrepo's stash_workspace() here since we need
# finer control over the merge behavior when we unstash everything
with _apply_workspace(repo, rev, force):
try:
repo.scm.merge(exp_rev, commit=False, squash=True)
except _SCMError as exc:
raise GitMergeError(str(exc), scm=repo.scm) # noqa: B904
repo.scm.reset()
if is_stash:
assert repo.tmp_dir is not None
args_path = os.path.join(repo.tmp_dir, BaseExecutor.PACKED_ARGS_FILE)
if os.path.exists(args_path):
remove(args_path)
_apply(repo, exp_rev, name=rev, is_stash=is_stash)
dvc_checkout(repo, **kwargs)
repo.scm.set_ref(EXEC_APPLY, exp_rev)
logger.info(
"Changes for experiment '%s' have been applied to your current workspace.",
rev,
ui.write(
f"Changes for experiment '{rev}' have been applied to your current workspace.",
)
@contextmanager
def _apply_workspace(repo: "Repo", rev: str, force: bool):
from scmrepo.exceptions import MergeConflictError
from scmrepo.exceptions import SCMError as _SCMError
def _apply(repo: "Repo", rev: str, name: Optional[str] = None, is_stash: bool = False):
exps: "Experiments" = repo.experiments
assert isinstance(repo.scm, Git)
if repo.scm.is_dirty(untracked_files=True):
logger.debug("Stashing workspace")
stash_rev: Optional[str] = repo.scm.stash.push(include_untracked=True)
else:
stash_rev = None
try:
yield
except Exception: # pylint: disable=broad-except
if stash_rev:
_clean_and_pop(repo.scm)
raise
if not stash_rev:
return
try:
repo.scm.reset()
repo.scm.stash.apply(stash_rev, skip_conflicts=force)
repo.scm.stash.drop()
except (MergeConflictError, _SCMError) as exc:
_clean_and_pop(repo.scm)
raise ApplyConflictError(rev) from exc
except Exception: # pylint: disable=broad-except
_clean_and_pop(repo.scm)
raise
def _clean_and_pop(scm: "Git"):
"""Revert any changes and pop the last stash entry."""
scm.reset(hard=True)
if scm.is_dirty(untracked_files=True):
# drop any changes to untracked files before popping stash
scm.stash.push(include_untracked=True)
scm.stash.drop()
scm.stash.pop()
with exps.apply_stash.preserve_workspace(rev, name=name):
with repo.scm.detach_head(rev, force=True):
if is_stash:
assert repo.tmp_dir is not None
args_path = os.path.join(repo.tmp_dir, BaseExecutor.PACKED_ARGS_FILE)
if os.path.exists(args_path):
remove(args_path)
+72
View File
@@ -0,0 +1,72 @@
import logging
import os
from typing import TYPE_CHECKING, Optional, Union
from dvc.fs import localfs
from dvc_objects.db import ObjectDB
from .serialize import DeserializeError, SerializableError, SerializableExp
from .utils import EXEC_TMP_DIR
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
class ExpCache:
"""Serialized experiment state cache.
ODB with git SHAs as keys. Objects can be either SerializableExp or
SerializableError.
"""
CACHE_DIR = os.path.join(EXEC_TMP_DIR, "cache")
def __init__(self, repo: "Repo"):
path = os.path.join(repo.tmp_dir, self.CACHE_DIR)
self.odb = ObjectDB(localfs, path)
def delete(self, rev: str):
self.odb.delete(rev)
def put(
self,
exp: Union[SerializableExp, SerializableError],
rev: Optional[str] = None,
force: bool = False,
):
rev = rev or getattr(exp, "rev", None)
assert rev
assert rev != "workspace"
if force or not self.odb.exists(rev):
try:
self.delete(rev)
except FileNotFoundError:
pass
self.odb.add_bytes(rev, exp.as_bytes())
logger.trace( # type: ignore[attr-defined]
"ExpCache: cache put '%s'", rev[:7]
)
def get(self, rev: str) -> Optional[Union[SerializableExp, SerializableError]]:
obj = self.odb.get(rev)
try:
with obj.fs.open(obj.path, "rb") as fobj:
data = fobj.read()
except FileNotFoundError:
logger.trace( # type: ignore[attr-defined]
"ExpCache: cache miss '%s'", rev[:7]
)
return None
for typ in (SerializableExp, SerializableError):
try:
exp = typ.from_bytes(data) # type: ignore[attr-defined]
logger.trace( # type: ignore[attr-defined]
"ExpCache: cache load '%s'", rev[:7]
)
return exp
except DeserializeError:
continue
logger.debug("ExpCache: unknown object type for '%s'", rev)
return None
+12
View File
@@ -0,0 +1,12 @@
from typing import TYPE_CHECKING
from dvc.ui import ui
if TYPE_CHECKING:
from dvc.repo import Repo
def clean(repo: "Repo"):
ui.write("Cleaning up dvc-task messages...")
repo.experiments.celery_queue.celery.clean()
ui.write("Done!")
-12
View File
@@ -66,18 +66,6 @@ class MultipleBranchError(DvcException):
self.ref_infos = ref_infos
class ApplyConflictError(InvalidArgumentError):
def __init__(self, name):
super().__init__(
f"Experiment '{name}' cannot be applied to because your current "
"workspace contains changes which would be overwritten. Either "
"'git stash' your current changes before applying this "
"experiment, or re-run this command with '--force' to overwrite "
"your current changes."
)
self.name = name
class AmbiguousExpRefInfo(InvalidArgumentError):
def __init__(
self,
+60 -48
View File
@@ -21,6 +21,7 @@ from typing import (
Union,
)
from funcy import get_in
from scmrepo.exceptions import SCMError
from dvc.env import DVC_EXP_AUTO_PUSH, DVC_EXP_GIT_REMOTE
@@ -32,6 +33,7 @@ from dvc.repo.experiments.refs import (
EXEC_CHECKPOINT,
ExpRefInfo,
)
from dvc.repo.experiments.utils import to_studio_params
from dvc.stage.serialize import to_lockfile
from dvc.ui import ui
from dvc.utils import dict_sha256, env2bool, relpath
@@ -334,21 +336,17 @@ class BaseExecutor(ABC):
# fetch experiments
try:
dest_scm.fetch_refspecs(
self.git_url,
[f"{ref}:{ref}" for ref in refs],
on_diverged=on_diverged_ref,
force=force,
**kwargs,
)
refspecs = [f"{ref}:{ref}" for ref in refs]
# update last run checkpoint (if it exists)
if has_checkpoint:
dest_scm.fetch_refspecs(
self.git_url,
[f"{EXEC_CHECKPOINT}:{EXEC_CHECKPOINT}"],
force=True,
**kwargs,
)
refspecs.append(f"{EXEC_CHECKPOINT}:{EXEC_CHECKPOINT}")
dest_scm.fetch_refspecs(
self.git_url,
refspecs,
on_diverged=on_diverged_ref,
force=force or has_checkpoint,
**kwargs,
)
except SCMError:
pass
@@ -545,43 +543,60 @@ class BaseExecutor(ABC):
log_errors: bool = True,
**kwargs,
):
from dvc_studio_client.post_live_metrics import post_live_metrics
from dvc.repo import Repo
from dvc.stage.monitor import CheckpointKilledError
dvc = Repo(os.path.join(info.root_dir, info.dvc_dir))
info.status = TaskStatus.RUNNING
if infofile is not None:
info.dump_json(infofile)
if cls.QUIET:
dvc.scm_context.quiet = cls.QUIET
old_cwd = os.getcwd()
if info.wdir:
os.chdir(os.path.join(dvc.scm.root_dir, info.wdir))
else:
os.chdir(dvc.root_dir)
try:
logger.debug("Running repro in '%s'", os.getcwd())
yield dvc
info.status = TaskStatus.SUCCESS
except CheckpointKilledError:
info.status = TaskStatus.FAILED
raise
except DvcException:
if log_errors:
logger.exception("")
info.status = TaskStatus.FAILED
raise
except Exception:
if log_errors:
logger.exception("unexpected error")
info.status = TaskStatus.FAILED
raise
finally:
with Repo(os.path.join(info.root_dir, info.dvc_dir)) as dvc:
info.status = TaskStatus.RUNNING
if infofile is not None:
info.dump_json(infofile)
dvc.close()
os.chdir(old_cwd)
if cls.QUIET:
dvc.scm_context.quiet = cls.QUIET
old_cwd = os.getcwd()
if info.wdir:
os.chdir(os.path.join(dvc.scm.root_dir, info.wdir))
else:
os.chdir(dvc.root_dir)
try:
post_live_metrics(
"start",
info.baseline_rev,
info.name,
"dvc",
params=to_studio_params(dvc.params.show()),
)
logger.debug("Running repro in '%s'", os.getcwd())
yield dvc
info.status = TaskStatus.SUCCESS
except CheckpointKilledError:
info.status = TaskStatus.FAILED
raise
except DvcException:
if log_errors:
logger.exception("")
info.status = TaskStatus.FAILED
raise
except Exception:
if log_errors:
logger.exception("unexpected error")
info.status = TaskStatus.FAILED
raise
finally:
post_live_metrics(
"done",
info.baseline_rev,
info.name,
"dvc",
experiment_rev=dvc.experiments.scm.get_ref(EXEC_BRANCH),
metrics=get_in(dvc.metrics.show(), ["", "data"]),
)
if infofile is not None:
info.dump_json(infofile)
os.chdir(old_cwd)
@classmethod
def _repro_args(cls, dvc):
@@ -702,13 +717,10 @@ class BaseExecutor(ABC):
@staticmethod
def _set_log_level(level):
from dvc.logger import disable_other_loggers
# When executor.reproduce is run in a multiprocessing child process,
# dvc.cli.main will not be called for that child process so we need to
# setup logging ourselves
dvc_logger = logging.getLogger("dvc")
disable_other_loggers()
if level is not None:
dvc_logger.setLevel(level)
+4 -11
View File
@@ -5,7 +5,6 @@ from tempfile import mkdtemp
from typing import TYPE_CHECKING, Optional, Union
from funcy import retry
from scmrepo.exceptions import SCMError as _SCMError
from shortuuid import uuid
from dvc.lock import LockError
@@ -20,7 +19,7 @@ from dvc.repo.experiments.refs import (
EXPS_TEMP,
)
from dvc.repo.experiments.utils import EXEC_TMP_DIR, get_exp_rwlock
from dvc.scm import SCM, Git, GitMergeError
from dvc.scm import SCM, Git
from dvc.utils.fs import remove
from dvc.utils.objects import cached_property
@@ -134,10 +133,7 @@ class TempDirExecutor(BaseLocalExecutor):
self.scm.checkout(head, detach=True)
merge_rev = self.scm.get_ref(EXEC_MERGE)
try:
self.scm.merge(merge_rev, squash=True, commit=False)
except _SCMError as exc:
raise GitMergeError(str(exc), scm=self.scm) # noqa: B904
self.scm.stash.apply(merge_rev)
def _config(self, cache_dir):
local_config = os.path.join(
@@ -153,7 +149,7 @@ class TempDirExecutor(BaseLocalExecutor):
self, repo: "Repo", rev: str, run_cache: bool = True # noqa: ARG002
):
"""Initialize DVC cache."""
self._config(repo.odb.repo.path)
self._config(repo.cache.repo.path)
def cleanup(self, infofile: str):
super().cleanup(infofile)
@@ -229,10 +225,7 @@ class WorkspaceExecutor(BaseLocalExecutor):
)
)
merge_rev = self.scm.get_ref(EXEC_MERGE)
try:
self.scm.merge(merge_rev, squash=True, commit=False)
except _SCMError as exc:
raise GitMergeError(str(exc), scm=self.scm) # noqa: B904
self.scm.stash.apply(merge_rev)
if branch:
self.scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
elif scm.get_ref(EXEC_BRANCH):
+3 -3
View File
@@ -169,7 +169,7 @@ class SSHExecutor(BaseExecutor):
head = EXEC_BRANCH if branch else EXEC_HEAD
self._ssh_cmd(fs, f"git checkout {head}")
merge_rev = scm.get_ref(EXEC_MERGE)
self._ssh_cmd(fs, f"git merge --squash --no-commit {merge_rev}")
self._ssh_cmd(fs, f"git stash apply {merge_rev}")
if self._setup_script:
self._init_setup_script(fs)
@@ -217,12 +217,12 @@ class SSHExecutor(BaseExecutor):
@contextmanager
def get_odb(self):
from dvc.odbmgr import ODBManager, get_odb
from dvc.cachemgr import CacheManager, get_odb
cache_path = posixpath.join(
self._repo_abspath,
self.dvc_dir,
ODBManager.CACHE_DIR,
CacheManager.CACHE_DIR,
)
with self.sshfs() as fs:
+6 -7
View File
@@ -21,9 +21,9 @@ from dvc.exceptions import DvcException
from dvc.stage import PipelineStage
if TYPE_CHECKING:
from dvc.dependency import Dependency
from dvc.dvcfile import ProjectFile, SingleStageFile
from dvc.repo import Repo
from dvc.dependency import Dependency
from dvc.ui import ui
@@ -218,13 +218,12 @@ def init(
provided=overrides,
stream=stream,
)
elif "live" in overrides:
# suppress `metrics`/`plots` if live is selected.
defaults.pop("metrics", None)
defaults.pop("plots", None)
else:
if "live" in overrides:
# suppress `metrics`/`plots` if live is selected.
defaults.pop("metrics", None)
defaults.pop("plots", None)
else:
defaults.pop("live", None) # suppress live otherwise
defaults.pop("live", None) # suppress live otherwise
context: Dict[str, str] = {**defaults, **overrides}
assert "cmd" in context
+7 -6
View File
@@ -295,7 +295,7 @@ class BaseStashQueue(ABC):
output.
"""
def _stash_exp( # noqa: C901
def _stash_exp( # noqa: PLR0915, C901
self,
*args,
params: Optional[Dict[str, List[str]]] = None,
@@ -323,12 +323,12 @@ class BaseStashQueue(ABC):
.. _Hydra Override:
https://hydra.cc/docs/next/advanced/override_grammar/basic/
"""
with self.scm.detach_head(client="dvc") as orig_head:
stash_head = orig_head
if baseline_rev is None:
baseline_rev = orig_head
with self.scm.stash_workspace(reinstate_index=True) as workspace:
with self.scm.detach_head(client="dvc") as orig_head:
stash_head = orig_head
if baseline_rev is None:
baseline_rev = orig_head
with self.scm.stash_workspace() as workspace:
try:
if workspace:
self.stash.apply(workspace)
@@ -472,6 +472,7 @@ class BaseStashQueue(ABC):
force=True,
allow_missing=True,
data_only=True,
relink=False,
)
@staticmethod
+6 -2
View File
@@ -90,7 +90,7 @@ class LocalCeleryQueue(BaseStashQueue):
"dvc_task.proc.tasks",
],
)
app.conf.update({"task_acks_late": True})
app.conf.update({"task_acks_late": True, "result_expires": None})
return app
@cached_property
@@ -138,6 +138,9 @@ class LocalCeleryQueue(BaseStashQueue):
wdir_hash = hashlib.sha256(self.wdir.encode("utf-8")).hexdigest()[:6]
node_name = f"dvc-exp-{wdir_hash}-{num}@localhost"
cmd = ["exp", "queue-worker", node_name]
if num == 1:
# automatically run celery cleanup when primary worker shuts down
cmd.append("--clean")
name = f"dvc-exp-worker-{num}"
logger.debug("start a new worker: %s, node: %s", name, node_name)
@@ -336,7 +339,8 @@ class LocalCeleryQueue(BaseStashQueue):
task_id,
remained_entries[entry],
)
self.celery.backend.mark_as_failure(task_id, None)
backend = self.celery.backend
backend.mark_as_failure(task_id, None) # type: ignore[attr-defined]
if remained_revs:
raise CannotKillTasksError(remained_revs)
+4 -3
View File
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
from dvc.repo.experiments.stash import ExpStashEntry
def remove_tasks( # noqa: C901
def remove_tasks( # noqa: C901, PLR0912
celery_queue: "LocalCeleryQueue",
queue_entries: Iterable["QueueEntry"],
):
@@ -40,7 +40,7 @@ def remove_tasks( # noqa: C901
msg,
queue_entry,
) in celery_queue._iter_queued(): # pylint: disable=protected-access
if queue_entry.stash_rev in stash_revs:
if queue_entry.stash_rev in stash_revs and msg.delivery_tag:
celery_queue.celery.reject(msg.delivery_tag)
finally:
celery_queue.stash.remove_revs(list(stash_revs.values()))
@@ -56,7 +56,8 @@ def remove_tasks( # noqa: C901
result: AsyncResult = AsyncResult(task_id)
if result is not None:
result.forget()
celery_queue.celery.purge(msg.delivery_tag)
if msg.delivery_tag:
celery_queue.celery.purge(msg.delivery_tag)
finally:
if celery_queue.failed_stash:
celery_queue.failed_stash.remove_revs(failed_stash_revs)
+31 -31
View File
@@ -28,17 +28,17 @@ def setup_exp(entry_dict: Dict[str, Any]) -> "BaseExecutor":
from dvc.repo import Repo
entry = QueueEntry.from_dict(entry_dict)
repo = Repo(entry.dvc_root)
# TODO: split executor.init_cache into separate subtask - we can release
# exp.scm_lock before DVC push
executor = BaseStashQueue.init_executor(
repo.experiments,
entry,
TempDirExecutor,
location="dvc-task",
)
infofile = repo.experiments.celery_queue.get_infofile_path(entry.stash_rev)
executor.info.dump_json(infofile)
with Repo(entry.dvc_root) as repo:
# TODO: split executor.init_cache into separate subtask - we can release
# exp.scm_lock before DVC push
executor = BaseStashQueue.init_executor(
repo.experiments,
entry,
TempDirExecutor,
location="dvc-task",
)
infofile = repo.experiments.celery_queue.get_infofile_path(entry.stash_rev)
executor.info.dump_json(infofile)
return executor
@@ -59,23 +59,23 @@ def collect_exp(
from dvc.repo import Repo
entry = QueueEntry.from_dict(entry_dict)
repo = Repo(entry.dvc_root)
celery_queue = repo.experiments.celery_queue
infofile = celery_queue.get_infofile_path(entry.stash_rev)
executor_info = ExecutorInfo.load_json(infofile)
logger.debug("Collecting experiment info '%s'", str(executor_info))
executor = TempDirExecutor.from_info(executor_info)
exec_result = executor_info.result
try:
if exec_result is not None:
BaseStashQueue.collect_executor(repo.experiments, executor, exec_result)
else:
logger.debug("Experiment failed (Exec result was None)")
celery_queue.stash_failed(entry)
except Exception: # pylint: disable=broad-except
# Log exceptions but do not re-raise so that task chain execution
# continues
logger.exception("Failed to collect experiment")
with Repo(entry.dvc_root) as repo:
celery_queue = repo.experiments.celery_queue
infofile = celery_queue.get_infofile_path(entry.stash_rev)
executor_info = ExecutorInfo.load_json(infofile)
logger.debug("Collecting experiment info '%s'", str(executor_info))
executor = TempDirExecutor.from_info(executor_info)
exec_result = executor_info.result
try:
if exec_result is not None:
BaseStashQueue.collect_executor(repo.experiments, executor, exec_result)
else:
logger.debug("Experiment failed (Exec result was None)")
celery_queue.stash_failed(entry)
except Exception: # pylint: disable=broad-except
# Log exceptions but do not re-raise so that task chain execution
# continues
logger.exception("Failed to collect experiment")
return executor.root_dir
@@ -102,9 +102,9 @@ def run_exp(entry_dict: Dict[str, Any]) -> None:
from dvc.repo import Repo
entry = QueueEntry.from_dict(entry_dict)
repo = Repo(entry.dvc_root)
queue = repo.experiments.celery_queue
infofile = queue.get_infofile_path(entry.stash_rev)
with Repo(entry.dvc_root) as repo:
queue = repo.experiments.celery_queue
infofile = queue.get_infofile_path(entry.stash_rev)
executor = setup_exp.s(entry_dict)()
try:
cmd = ["dvc", "exp", "exec-run", "--infofile", infofile]
+1 -17
View File
@@ -13,7 +13,7 @@ from dvc.repo.experiments.exceptions import ExpQueueEmptyError
from dvc.repo.experiments.executor.base import ExecutorInfo, TaskStatus
from dvc.repo.experiments.executor.local import WorkspaceExecutor
from dvc.repo.experiments.refs import EXEC_BRANCH, WORKSPACE_STASH
from dvc.repo.experiments.utils import get_exp_rwlock, to_studio_params
from dvc.repo.experiments.utils import get_exp_rwlock
from dvc.rwlock import RWLOCK_FILE, RWLOCK_LOCK, SCHEMA
from dvc.utils import relpath
from dvc.utils.fs import remove
@@ -94,8 +94,6 @@ class WorkspaceQueue(BaseStashQueue):
def _reproduce_entry(
self, entry: QueueEntry, executor: "BaseExecutor"
) -> Dict[str, Dict[str, str]]:
from dvc_studio_client.post_live_metrics import post_live_metrics
from dvc.stage.monitor import CheckpointKilledError
results: Dict[str, Dict[str, str]] = defaultdict(dict)
@@ -103,13 +101,6 @@ class WorkspaceQueue(BaseStashQueue):
infofile = self.get_infofile_path(exec_name)
try:
rev = entry.stash_rev
post_live_metrics(
"start",
executor.info.baseline_rev,
executor.info.name,
"dvc",
params=to_studio_params(self.repo.params.show()),
)
exec_result = executor.reproduce(
info=executor.info,
rev=rev,
@@ -131,13 +122,6 @@ class WorkspaceQueue(BaseStashQueue):
except Exception as exc: # noqa: BLE001
raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'") from exc
finally:
post_live_metrics(
"done",
executor.info.baseline_rev,
executor.info.name,
"dvc",
experiment_rev=first(results.get(rev, {})),
)
executor.cleanup(infofile)
return results
+4
View File
@@ -7,6 +7,9 @@ from .exceptions import InvalidExpRefError
EXPS_NAMESPACE = "refs/exps"
EXPS_STASH = f"{EXPS_NAMESPACE}/stash"
WORKSPACE_STASH = EXPS_STASH
APPLY_NAMESPACE = f"{EXPS_NAMESPACE}/apply"
APPLY_HEAD = f"{APPLY_NAMESPACE}/ORIG_HEAD"
APPLY_STASH = f"{APPLY_NAMESPACE}/stash"
CELERY_STASH = f"{EXPS_NAMESPACE}/celery/stash"
CELERY_FAILED_STASH = f"{EXPS_NAMESPACE}/celery/failed"
EXEC_NAMESPACE = f"{EXPS_NAMESPACE}/exec"
@@ -18,6 +21,7 @@ EXEC_HEAD = f"{EXEC_NAMESPACE}/EXEC_HEAD"
EXEC_MERGE = f"{EXEC_NAMESPACE}/EXEC_MERGE"
EXPS_TEMP = f"{EXPS_NAMESPACE}/temp"
STASHES = {WORKSPACE_STASH, CELERY_STASH}
ITER_SKIP_NAMESPACES = {APPLY_NAMESPACE, EXEC_NAMESPACE}
class ExpRefInfo:
+4 -5
View File
@@ -61,20 +61,19 @@ def run( # noqa: C901
)
if hydra_sweep:
if kwargs.get("name"):
raise InvalidArgumentError(
"Sweep overrides can't be used alongside `--name`"
)
from dvc.utils.hydra import get_hydra_sweeps
sweeps = get_hydra_sweeps(path_overrides)
name_prefix = kwargs.get("name")
else:
sweeps = [path_overrides]
if not kwargs.get("checkpoint_resume", None):
kwargs["reset"] = True
for sweep_overrides in sweeps:
for idx, sweep_overrides in enumerate(sweeps):
if hydra_sweep and name_prefix is not None:
kwargs["name"] = f"{name_prefix}-{idx+1}"
queue_entry = repo.experiments.queue_one(
repo.experiments.celery_queue,
targets=targets,
+5 -2
View File
@@ -2,6 +2,8 @@ import logging
import os
from typing import TYPE_CHECKING, List, Optional
from pathspec import PathSpec
from dvc.scm import Git
from .exceptions import ExperimentExistsError
@@ -22,7 +24,7 @@ def _save_experiment(
name: Optional[str],
include_untracked: Optional[List[str]],
) -> str:
repo.commit([], force=True)
repo.commit([], force=True, relink=False)
name = name or get_random_exp_name(repo.scm, baseline_rev)
ref_info = ExpRefInfo(baseline_rev, name)
@@ -59,7 +61,8 @@ def save(
_, _, untracked = repo.scm.status()
if include_untracked:
untracked = [file for file in untracked if file not in include_untracked]
spec = PathSpec.from_lines("gitwildmatch", include_untracked)
untracked = [file for file in untracked if not spec.match_file(file)]
if untracked:
logger.warning(
(
+164
View File
@@ -0,0 +1,164 @@
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
from dvc.exceptions import DvcException
from dvc.repo.metrics.show import _gather_metrics
from dvc.repo.params.show import _gather_params
from dvc.utils import onerror_collect, relpath
if TYPE_CHECKING:
from dvc.repo import Repo
class DeserializeError(DvcException):
pass
class _ISOEncoder(json.JSONEncoder):
def default(self, o: object) -> Any:
if isinstance(o, datetime):
return o.isoformat()
return super().default(o)
@dataclass(frozen=True)
class SerializableExp:
"""Serializable experiment state."""
rev: str
timestamp: Optional[datetime] = None
params: Dict[str, Any] = field(default_factory=dict)
metrics: Dict[str, Any] = field(default_factory=dict)
deps: Dict[str, "_ExpDep"] = field(default_factory=dict)
outs: Dict[str, "_ExpOut"] = field(default_factory=dict)
status: Optional[str] = None
executor: Optional[str] = None
error: Optional["SerializableError"] = None
@classmethod
def from_repo(
cls,
repo: "Repo",
rev: Optional[str] = None,
onerror: Optional[Callable] = None,
**kwargs,
) -> "SerializableExp":
"""Returns a SerializableExp from the current repo state.
Params, metrics, deps, outs are filled via repo fs/index, all other fields
should be passed via kwargs.
"""
from dvc.dependency import ParamsDependency, RepoDependency
if not onerror:
onerror = onerror_collect
rev = rev or repo.get_rev()
assert rev
status: Optional[str] = kwargs.get("status")
# NOTE: _gather_params/_gather_metrics return defaultdict which is not
# supported in dataclasses.asdict() on all python releases
# see https://bugs.python.org/issue35540
params = dict(_gather_params(repo, onerror=onerror))
if status and status.lower() in ("queued", "failed"):
metrics: Dict[str, Any] = {}
else:
metrics = dict(
_gather_metrics(
repo,
targets=None,
rev=rev[:7],
recursive=False,
onerror=onerror_collect,
)
)
return cls(
rev=rev,
params=params,
metrics=metrics,
deps={
relpath(dep.fs_path, repo.root_dir): _ExpDep(
hash=dep.hash_info.value if dep.hash_info else None,
size=dep.meta.size if dep.meta else None,
nfiles=dep.meta.nfiles if dep.meta else None,
)
for dep in repo.index.deps
if not isinstance(dep, (ParamsDependency, RepoDependency))
},
outs={
relpath(out.fs_path, repo.root_dir): _ExpOut(
hash=out.hash_info.value if out.hash_info else None,
size=out.meta.size if out.meta else None,
nfiles=out.meta.nfiles if out.meta else None,
use_cache=out.use_cache,
is_data_source=out.stage.is_data_source,
)
for out in repo.index.outs
if not (out.is_metric or out.is_plot)
},
**kwargs,
)
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
def as_bytes(self) -> bytes:
return _ISOEncoder().encode(self.dumpd()).encode("utf-8")
@classmethod
def from_bytes(cls, data: bytes):
try:
parsed = json.loads(data)
if "timestamp" in parsed:
parsed["timestamp"] = datetime.fromisoformat(parsed["timestamp"])
return cls(**parsed)
except (TypeError, json.JSONDecodeError) as exc:
raise DeserializeError("failed to load SerializableExp") from exc
@property
def contains_error(self) -> bool:
return (
self.error is not None
or self.params.get("error")
or any(value.get("error") for value in self.params.values())
or self.metrics.get("error")
or any(value.get("error") for value in self.metrics.values())
)
@dataclass(frozen=True)
class _ExpDep:
hash: Optional[str] # noqa: A003
size: Optional[int]
nfiles: Optional[int]
@dataclass(frozen=True)
class _ExpOut:
hash: Optional[str] # noqa: A003
size: Optional[int]
nfiles: Optional[int]
use_cache: bool
is_data_source: bool
@dataclass(frozen=True)
class SerializableError:
msg: str
type: str = "" # noqa: A003
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
def as_bytes(self) -> bytes:
return json.dumps(self.dumpd()).encode("utf-8")
@classmethod
def from_bytes(cls, data: bytes):
try:
parsed = json.loads(data)
return cls(**parsed)
except (TypeError, json.JSONDecodeError) as exc:
raise DeserializeError("failed to load SerializableError") from exc
+113 -72
View File
@@ -18,12 +18,11 @@ from typing import (
from scmrepo.exceptions import SCMError as InnerScmError
from dvc.repo.metrics.show import _gather_metrics
from dvc.repo.params.show import _gather_params
from dvc.exceptions import DvcException
from dvc.scm import Git, SCMError, iter_revs, resolve_rev
from dvc.utils import error_handler, onerror_collect, relpath
from .refs import ExpRefInfo
from .serialize import SerializableError, SerializableExp
if TYPE_CHECKING:
from scmrepo.git.objects import GitCommit
@@ -40,85 +39,116 @@ class ExpStatus(Enum):
Failed = 3
class _CachedError(DvcException):
def __init__(self, msg, typ, *args):
super().__init__(msg, *args)
self.typ = typ
def _is_scm_error(collected_exp: Dict[str, Any]) -> bool:
if "error" in collected_exp and (
isinstance(collected_exp["error"], (SCMError, InnerScmError))
isinstance(collected_exp["error"], (_CachedError, SCMError, InnerScmError))
):
return True
return False
def _show_onerror_collect(result: Dict, exception: Exception, *args, **kwargs):
onerror_collect(result, exception, *args, **kwargs)
result["data"] = {}
def _format_exp(exp: SerializableExp) -> Dict[str, Any]:
# SerializableExp always includes error but we need to strip it from show
# output when it is false-y to maintain compatibility w/tools that consume
# json output and assume that "error" key presence means there was an error
exp_dict = exp.dumpd()
if "error" in exp_dict and not exp_dict["error"]:
del exp_dict["error"]
return {"data": exp_dict}
def _format_error(error: SerializableError):
msg = error.msg or "None"
return {"data": {}, "error": _CachedError(msg, error.type)}
@error_handler
def collect_experiment_commit(
repo: "Repo",
exp_rev: str,
status: ExpStatus = ExpStatus.Success,
param_deps=False,
running: Optional[Dict[str, Any]] = None,
onerror: Optional[Callable] = None,
param_deps: bool = False,
force: bool = False,
**kwargs,
) -> Dict[str, Any]:
from dvc.dependency import ParamsDependency, RepoDependency
cache = repo.experiments.cache
# TODO: support filtering serialized exp when param_deps is set
if exp_rev != "workspace" and not (force or param_deps):
cached_exp = cache.get(exp_rev)
if cached_exp:
if status == ExpStatus.Running or (
isinstance(cached_exp, SerializableExp)
and cached_exp.status == ExpStatus.Running.name
):
# expire cached queued exp entry once we start running it
cache.delete(exp_rev)
elif isinstance(cached_exp, SerializableError):
return _format_error(cached_exp)
else:
return _format_exp(cached_exp)
try:
exp = _collect_from_repo(
repo,
exp_rev,
status=status,
param_deps=param_deps,
force=force,
**kwargs,
)
if not (
exp_rev == "workspace"
or status == ExpStatus.Running
or param_deps
or exp.contains_error
):
cache.put(exp, force=True)
return _format_exp(exp)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
logger.debug("", exc_info=True)
error = SerializableError(str(exc), type(exc).__name__)
return _format_error(error)
result: Dict[str, Any] = defaultdict(dict)
def _collect_from_repo(
repo: "Repo",
exp_rev: str,
status: ExpStatus = ExpStatus.Success,
running: Optional[Dict[str, Any]] = None,
**kwargs,
) -> "SerializableExp":
running = running or {}
for rev in repo.brancher(revs=[exp_rev]):
with repo.switch(exp_rev) as rev:
if rev == "workspace":
if exp_rev != "workspace":
continue
result["timestamp"] = None
timestamp: Optional[datetime] = None
else:
commit = repo.scm.resolve_commit(rev)
result["timestamp"] = datetime.fromtimestamp(commit.commit_time)
timestamp = datetime.fromtimestamp(commit.commit_time)
params = _gather_params(repo, targets=None, deps=param_deps, onerror=onerror)
if params:
result["params"] = params
result["deps"] = {
relpath(dep.fs_path, repo.root_dir): {
"hash": dep.hash_info.value,
"size": dep.meta.size,
"nfiles": dep.meta.nfiles,
}
for dep in repo.index.deps
if not isinstance(dep, (ParamsDependency, RepoDependency))
}
result["outs"] = {
relpath(out.fs_path, repo.root_dir): {
"hash": out.hash_info.value,
"size": out.meta.size,
"nfiles": out.meta.nfiles,
"use_cache": out.use_cache,
"is_data_source": out.stage.is_data_source,
}
for out in repo.index.outs
if not (out.is_metric or out.is_plot)
}
result["status"] = status.name
if status == ExpStatus.Running:
result["executor"] = running.get(exp_rev, {}).get("location", None)
executor: Optional[str] = running.get(exp_rev, {}).get("location", None)
else:
result["executor"] = None
executor = None
if status == ExpStatus.Failed:
result["error"] = {
"msg": "Experiment run failed.",
"type": "",
}
if status not in {ExpStatus.Queued, ExpStatus.Failed}:
vals = _gather_metrics(
repo, targets=None, rev=rev, recursive=False, onerror=onerror
error: Optional["SerializableError"] = SerializableError(
"Experiment run failed."
)
result["metrics"] = vals
else:
error = None
return result
return SerializableExp.from_repo(
repo,
rev=exp_rev,
timestamp=timestamp,
status=status.name,
executor=executor,
error=error,
)
def _collect_complete_experiment(
@@ -170,6 +200,7 @@ def _collect_branch(
repo: "Repo",
baseline: str,
running: Dict[str, Any],
refs: Optional[Iterable[str]] = None,
**kwargs,
) -> Dict[str, Dict[str, Any]]:
results: Dict[str, Dict[str, Any]] = OrderedDict()
@@ -188,7 +219,11 @@ def _collect_branch(
commits: List[Tuple[str, "GitCommit", str, List[str]]] = []
assert isinstance(repo.scm, Git)
for ref in repo.scm.iter_refs(base=str(ref_info)):
if refs:
ref_it = (ref for ref in iter(refs) if ref.startswith(str(ref_info)))
else:
ref_it = repo.scm.iter_refs(base=str(ref_info))
for ref in ref_it:
try:
commit = repo.scm.resolve_commit(ref)
exp_rev = resolve_rev(repo.scm, ref)
@@ -217,19 +252,22 @@ def _collect_branch(
def get_branch_names(
scm: "Git", baseline_set: Iterable[str]
scm: "Git", baseline_set: Iterable[str], refs: Optional[Iterable[str]] = None
) -> Dict[str, Optional[str]]:
names: Dict[str, Optional[str]] = {}
for base in [
bases = [
f"refs/exps/{baseline[:2]}/{baseline[2:]}/" for baseline in baseline_set
] + ["refs/heads/", "refs/tags/"]:
for ref in scm.iter_refs(base=base):
if ref:
] + ["refs/heads/", "refs/tags/"]
ref_it = iter(refs) if refs else scm.iter_refs()
for ref in ref_it:
for base in bases:
if ref.startswith(base):
try:
rev = scm.get_ref(ref)
names[rev] = ref[len(base) :]
except KeyError:
logger.debug("unreosolved ref %s", ref)
logger.debug("unresolved ref %s", ref)
break
logger.debug("found refs for revs: %s", names)
return names
@@ -377,13 +415,12 @@ def move_properties_to_head(result: Dict[str, Dict[str, Dict[str, Any]]]):
if rev_result["status"] == ExpStatus.Running.name:
head["status"] = ExpStatus.Running.name
rev_result["status"] = ExpStatus.Success.name
else:
if rev_result["checkpoint_tip"] == rev:
head = rev_result
checkpoint = True
elif rev_result["checkpoint_tip"] == rev:
head = rev_result
checkpoint = True
def show(
def show( # noqa: PLR0913
repo: "Repo",
all_branches=False,
all_tags=False,
@@ -396,12 +433,11 @@ def show(
param_deps=False,
onerror: Optional[Callable] = None,
fetch_running: bool = True,
force: bool = False,
):
if repo.scm.no_commits:
return {}
onerror = onerror or _show_onerror_collect
res: Dict[str, Dict] = defaultdict(OrderedDict)
if not any([revs, all_branches, all_tags, all_commits]):
@@ -415,7 +451,8 @@ def show(
found_revs.update(
iter_revs(repo.scm, revs, num, all_branches, all_tags, all_commits)
)
branch_names = get_branch_names(repo.scm, found_revs)
cached_refs = list(repo.scm.iter_refs())
branch_names = get_branch_names(repo.scm, found_revs, refs=cached_refs)
running: Dict[str, Dict] = repo.experiments.get_running_exps(
fetch_refs=fetch_running
@@ -427,7 +464,7 @@ def show(
found_revs,
running,
param_deps=param_deps,
onerror=onerror,
force=force,
)
if not hide_queued
else {}
@@ -439,6 +476,7 @@ def show(
running,
param_deps=param_deps,
onerror=onerror,
force=force,
)
failed_experiments = (
@@ -448,6 +486,7 @@ def show(
running,
param_deps=param_deps,
onerror=onerror,
force=force,
)
if not hide_failed
else {}
@@ -460,6 +499,8 @@ def show(
running=running,
param_deps=param_deps,
onerror=onerror,
force=force,
refs=cached_refs,
)
update_new(res, failed_experiments)
+130 -1
View File
@@ -1,8 +1,18 @@
import logging
import re
from typing import Dict, Iterable, NamedTuple, Optional
from contextlib import contextmanager
from typing import Dict, Iterable, Iterator, NamedTuple, Optional
from scmrepo.git import Stash
from dvc.exceptions import DvcException
from dvc_objects.fs.local import localfs
from dvc_objects.fs.utils import as_atomic
from .refs import APPLY_HEAD, APPLY_STASH
logger = logging.getLogger(__name__)
class ExpStashEntry(NamedTuple):
"""Experiment stash entry.
@@ -72,3 +82,122 @@ class ExpStash(Stash):
reverse=True,
):
self.drop(index)
class ApplyStashEntry(NamedTuple):
"""Apply stash entry.
stash_index: Stash index for this entry. Can be None if this commit
is not pushed onto the stash ref.
head_rev: HEAD Git commit prior to exp apply.
rev: Applied experiment commit.
name: Optional applied exp name.
"""
stash_index: Optional[int]
head_rev: str
rev: str
name: Optional[str]
class ApplyStash(Stash):
DEFAULT_STASH = APPLY_STASH
MESSAGE_FORMAT = "dvc-exp-apply:{head_rev}:{rev}:{name}"
MESSAGE_RE = re.compile(
r"(?:commit: )"
r"dvc-exp-apply:(?P<head_rev>[0-9a-f]+):(?P<rev>[0-9a-f]+)"
r":(?P<name>[^~^:\\?\[\]*]*)"
)
@property
def stash_revs(self) -> Dict[str, ApplyStashEntry]:
revs = {}
for i, entry in enumerate(self):
msg = entry.message.decode("utf-8").strip()
m = self.MESSAGE_RE.match(msg)
if m:
revs[entry.new_sha.decode("utf-8")] = ApplyStashEntry(
i,
m.group("head_rev"),
m.group("rev"),
m.group("name"),
)
return revs
@classmethod
def format_message(
cls,
head_rev: str,
rev: str,
name: Optional[str] = None,
) -> str:
return cls.MESSAGE_FORMAT.format(
head_rev=head_rev, rev=rev, name=name if name else ""
)
@contextmanager
def preserve_workspace(
self, rev: str, name: Optional[str] = None
) -> Iterator[Optional[str]]:
if len(self):
logger.debug("Clearing existing exp-apply stash")
self.clear()
head = self.scm.get_rev()
self.scm.set_ref(APPLY_HEAD, head)
message = self.format_message(head, rev, name=name)
stash_rev = self.push(message=message, include_untracked=True)
try:
yield stash_rev
if stash_rev:
self._apply_difference(stash_rev, rev)
except Exception: # pylint: disable=broad-except
self.revert_workspace()
raise
def _apply_difference(self, stash_rev: str, rev: str):
"""Selectively apply changes from stash_rev.
Only changes to files from left which do not exist in right will be applied.
"""
self._copy_difference(stash_rev, rev)
commit = self.scm.resolve_commit(stash_rev)
for parent_rev in commit.parents:
parent_commit = self.scm.resolve_commit(parent_rev)
if parent_commit.message.startswith("untracked files on "):
self._copy_difference(parent_rev, rev)
def _copy_difference(self, left_rev: str, right_rev: str):
left_fs = self.scm.get_fs(left_rev)
right_fs = self.scm.get_fs(right_rev)
paths = [path for path in left_fs.find("/") if not right_fs.exists(path)]
dest_paths = [
localfs.path.join(self.scm.root_dir, left_fs.path.relpath(path, "/"))
for path in paths
]
for src, dest in zip(paths, dest_paths):
with as_atomic(localfs, dest, create_parents=True) as tmp_file:
left_fs.get_file(src, tmp_file)
def revert_workspace(self):
apply_head = self.scm.get_ref(self.ref)
head = self.scm.get_rev()
if apply_head != head:
raise DvcException(
f"Cannot revert workspace, current HEAD '{head[:7]}' does not match the"
f" pre-apply HEAD '{apply_head[:7]}'"
)
self.scm.reset(hard=True)
if len(self):
# In the event that the apply-stash and current workspace contain
# conflicting untracked files, we do:
# 1. stash the current untracked files
# 2. restore/pop the apply-stash (with untracked files)
# 3. restore/pop the untracked files from (1) and ignore any conflicts
# (forcefully reverting to the apply-stash version)
workspace_rev = self.scm.stash.push(include_untracked=True)
try:
self.pop()
finally:
if workspace_rev:
self.scm.stash.pop(skip_conflicts=True)
self.scm.remove_ref(self.ref)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
@locked
def fetch( # noqa: C901
def fetch( # noqa: C901, PLR0913
self,
targets=None,
jobs=None,
+2 -2
View File
@@ -23,7 +23,7 @@ def _validate_args(**kwargs):
@locked
def gc(
def gc( # noqa: PLR0913
self: "Repo",
all_branches: bool = False,
cloud: bool = False,
@@ -85,7 +85,7 @@ def gc(
).values():
used_obj_ids.update(obj_ids)
for scheme, odb in self.odb.by_scheme():
for scheme, odb in self.cache.by_scheme():
if not odb:
continue
+11 -11
View File
@@ -33,10 +33,10 @@ def get_pipeline(pipelines, node):
return found[0]
def get_pipelines(G: "DiGraph"):
def get_pipelines(graph: "DiGraph"):
import networkx as nx
return [G.subgraph(c).copy() for c in nx.weakly_connected_components(G)]
return [graph.subgraph(c).copy() for c in nx.weakly_connected_components(graph)]
def collect_pipeline(stage: "Stage", graph: "DiGraph") -> Iterator["Stage"]:
@@ -97,7 +97,7 @@ def build_graph(stages, outs_trie=None):
from .trie import build_outs_trie
G = nx.DiGraph()
graph = nx.DiGraph()
# Use trie to efficiently find overlapping outs and deps
outs_trie = outs_trie or build_outs_trie(stages)
@@ -108,7 +108,7 @@ def build_graph(stages, outs_trie=None):
raise StagePathAsOutputError(stage, str(out))
# Building graph
G.add_nodes_from(stages)
graph.add_nodes_from(stages)
for stage in stages:
for dep in stage.deps:
if dep.fs_path is None:
@@ -119,10 +119,10 @@ def build_graph(stages, outs_trie=None):
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)
graph.add_edges_from((stage, out.stage) for out in overlapping)
check_acyclic(graph)
return G
return graph
# NOTE: using stage graph instead of just list of stages to make sure that it
@@ -131,9 +131,9 @@ def build_graph(stages, outs_trie=None):
def build_outs_graph(graph, outs_trie):
import networkx as nx
G = nx.DiGraph()
outs_graph = nx.DiGraph()
G.add_nodes_from(outs_trie.values())
outs_graph.add_nodes_from(outs_trie.values())
for stage in graph.nodes():
for dep in stage.deps:
if dep.fs_path is None:
@@ -145,5 +145,5 @@ def build_outs_graph(graph, outs_trie):
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
outs_graph.add_edges_from((from_out, out) for out in overlapping)
return outs_graph
+1 -1
View File
@@ -14,7 +14,7 @@ from . import locked
@locked
@scm_context
def imp_url( # noqa: C901
def imp_url( # noqa: C901, PLR0913
self: "Repo",
url,
out=None,
+10 -5
View File
@@ -1,7 +1,7 @@
import logging
import os
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, List, Set, Tuple, Union, cast
from typing import TYPE_CHECKING, List, Set, Tuple, Union
if TYPE_CHECKING:
from dvc.dependency.base import Dependency
@@ -9,7 +9,8 @@ if TYPE_CHECKING:
from dvc.repo.index import Index, IndexView
from dvc.stage import Stage
from dvc.types import TargetType
from dvc_data.hashfile import HashInfo
from dvc_data.hashfile.hash_info import HashInfo
logger = logging.getLogger(__name__)
@@ -63,6 +64,8 @@ def unpartial_imports(index: Union["Index", "IndexView"]) -> int:
Returns:
Total number of files which were unpartialed.
"""
from dvc_data.hashfile.hash_info import HashInfo
updated = 0
for out in index.outs:
# we need to use view[key] here and since the out fields have not been
@@ -75,8 +78,10 @@ def unpartial_imports(index: Union["Index", "IndexView"]) -> int:
out.hash_info = dep.get_obj().hash_info
out.meta = dep.get_meta()
else:
out.hash_info = cast("HashInfo", entry.hash_info)
assert isinstance(entry.hash_info, HashInfo)
out.hash_info = entry.hash_info
out.meta = entry.meta
out.stage.md5 = out.stage.compute_md5()
out.stage.dump()
updated += out.meta.nfiles if out.meta.nfiles is not None else 1
return updated
@@ -106,7 +111,7 @@ def save_imports(
data_view = unfetched.data["repo"]
if len(data_view):
cache = repo.odb.local
cache = repo.cache.local
if not cache.fs.exists(cache.path):
os.makedirs(cache.path)
with TemporaryDirectory(dir=cache.path) as tmpdir:
@@ -114,7 +119,7 @@ def save_imports(
desc="Downloading imports from source",
unit="files",
) as cb:
checkout(data_view, tmpdir, cache.fs, callback=cb)
checkout(data_view, tmpdir, cache.fs, callback=cb, storage="data")
md5(data_view)
save(data_view, odb=cache, hardlink=True)
+111 -24
View File
@@ -112,6 +112,60 @@ def collect_files(
dirs[:] = [d for d in dirs if not is_out_or_ignored(root, d)]
def _load_data_from_outs(index, prefix, outs):
from dvc_data.index import DataIndexEntry
for out in outs:
if not out.use_cache:
continue
ws, key = out.index_key
entry = DataIndexEntry(
key=key,
meta=out.meta,
hash_info=out.hash_info,
)
if out.stage.is_import and not out.stage.is_repo_import:
dep = out.stage.deps[0]
entry.meta = dep.meta
entry.hash_info = dep.hash_info
# FIXME PyGTrie-based DataIndex doesn't remove entry.key during
# index.add, so we have to set the entry manually here to make
# index.view() work correctly.
index[(*prefix, ws, *key)] = entry
def _load_storage_from_out(storage_map, key, out):
from dvc.config import NoRemoteError
from dvc_data.index import FileStorage, ObjectStorage
if out.odb:
storage_map.add_data(ObjectStorage(key, out.odb))
storage_map.add_cache(ObjectStorage(key, out.cache))
try:
remote = out.repo.cloud.get_remote(out.remote)
if remote.fs.version_aware:
storage_map.add_remote(
FileStorage(
key=key,
fs=remote.fs,
path=remote.fs.path.join(remote.path, *key),
index=remote.index,
)
)
else:
storage_map.add_remote(ObjectStorage(key, remote.odb, index=remote.index))
except NoRemoteError:
pass
if out.stage.is_import and not out.stage.is_repo_import:
dep = out.stage.deps[0]
storage_map.add_data(FileStorage(key, dep.fs, dep.fs_path))
class Index:
def __init__(
self,
@@ -282,35 +336,60 @@ class Index:
return dict(by_workspace)
@cached_property
def data(self) -> "Dict[str, DataIndex]":
from collections import defaultdict
from dvc.config import NoRemoteError
from dvc_data.index import DataIndex
by_workspace: dict = defaultdict(DataIndex)
by_workspace["repo"] = DataIndex()
by_workspace["local"] = DataIndex()
def data_tree(self):
from dvc_data.hashfile.tree import Tree
tree = Tree()
for out in self.outs:
if not out.use_cache:
continue
workspace, key = out.index_key
data_index = by_workspace[workspace]
ws, key = out.index_key
entry = out.get_entry()
entry.key = key
data_index.add(entry)
tree.add((ws, *key), out.meta, out.hash_info)
data_index.odb_map[key] = out.odb
try:
data_index.remote_map[key] = self.repo.cloud.get_remote_odb(out.remote)
except NoRemoteError:
pass
tree.digest()
return dict(by_workspace)
return tree
@cached_property
def data(self) -> "Dict[str, DataIndex]":
from dvc_data.index import DataIndex
prefix: "DataIndexKey"
loaded = False
index = self.repo.data_index
if index is None:
index = DataIndex()
prefix = ("tree", self.data_tree.hash_info.value)
if index.has_node(prefix):
loaded = True
try:
if not loaded:
_load_data_from_outs(index, prefix, self.outs)
index.commit()
by_workspace = {}
by_workspace["repo"] = index.view((*prefix, "repo"))
by_workspace["local"] = index.view((*prefix, "local"))
for out in self.outs:
if not out.use_cache:
continue
ws, key = out.index_key
if ws not in by_workspace:
by_workspace[ws] = index.view((*prefix, ws))
data_index = by_workspace[ws]
_load_storage_from_out(data_index.storage_map, key, out)
return by_workspace
finally:
index.close()
@staticmethod
def _hash_targets(
@@ -459,7 +538,7 @@ class IndexView:
workspace, key = out.index_key
if filter_info and out.fs.path.isin(filter_info, out.fs_path):
key = key + out.fs.path.relparts(filter_info, out.fs_path)
if out.meta.isdir:
if out.meta.isdir or out.stage.is_import and out.stage.deps[0].meta.isdir:
prefixes[workspace].recursive.add(key)
prefixes[workspace].explicit.update(key[:i] for i in range(len(key), 0, -1))
return prefixes
@@ -511,6 +590,10 @@ def build_data_index(
from dvc_data.index.build import build_entries, build_entry
from dvc_data.index.save import build_tree
ignore = None
if workspace == "repo" and isinstance(fs, LocalFileSystem):
ignore = index.repo.dvcignore
data = DataIndex()
for key in index.data_keys.get(workspace, set()):
out_path = fs.path.join(path, *key)
@@ -523,7 +606,7 @@ def build_data_index(
state=index.repo.state,
)
except FileNotFoundError:
out_entry = DataIndexEntry(path=out_path, fs=fs)
out_entry = DataIndexEntry()
out_entry.key = key
data.add(out_entry)
@@ -532,7 +615,11 @@ def build_data_index(
continue
for entry in build_entries(
out_path, fs, compute_hash=compute_hash, state=index.repo.state
out_path,
fs,
compute_hash=compute_hash,
state=index.repo.state,
ignore=ignore,
):
if not entry.key or entry.key == ("",):
# NOTE: whether the root will be returned by build_entries
+1 -1
View File
@@ -143,7 +143,7 @@ def show(
except (SCMError, NoSCMError):
# SCMError - detached head
# NoSCMError - no repo case
pass # noqa: S110
pass
else:
if res.get("workspace") == res.get(active_branch):
res.pop("workspace", None)
+3 -3
View File
@@ -32,11 +32,11 @@ def move(self, from_path, to_path):
It only works with outputs generated by `add` or `import`,
also known as data sources.
"""
import dvc.output as Output
from dvc import output
from dvc.dvcfile import DVC_FILE_SUFFIX
from dvc.stage import Stage
from_out = Output.loads_from(Stage(self), [from_path])[0]
from_out = output.loads_from(Stage(self), [from_path])[0]
assert from_out.protocol == "local"
to_path = _expand_target_path(from_path, to_path)
@@ -71,7 +71,7 @@ def move(self, from_path, to_path):
else:
to_path = os.path.relpath(to_path, stage.wdir)
to_out = Output.loads_from(stage, [to_path], out.use_cache, out.metric)[0]
to_out = output.loads_from(stage, [to_path], out.use_cache, out.metric)[0]
out.move(to_out)
stage.save()
+1 -1
View File
@@ -153,7 +153,7 @@ def show(
except (SCMError, NoSCMError):
# SCMError - detached head
# NoSCMError - no repo case
pass # noqa: S110
pass
else:
if res.get("workspace") == res.get(active_branch):
res.pop("workspace", None)
+19 -53
View File
@@ -2,7 +2,7 @@ import csv
import io
import logging
import os
from collections import OrderedDict, defaultdict
from collections import defaultdict
from copy import deepcopy
from functools import partial
from multiprocessing import cpu_count
@@ -18,8 +18,8 @@ from typing import (
Union,
)
import dpath
import dpath.options
import dpath.util
from funcy import distinct, first, project
from dvc.exceptions import DvcException
@@ -74,7 +74,6 @@ class Plots:
recursive: bool = False,
onerror: Optional[Callable] = None,
props: Optional[Dict] = None,
config_files: Optional[Set[str]] = None,
) -> Iterator[Dict]:
"""Collects plots definitions and data sources.
@@ -131,7 +130,6 @@ class Plots:
targets=targets,
revision=rev,
onerror=onerror,
config_files=config_files,
props=props,
)
if definitions:
@@ -186,7 +184,6 @@ class Plots:
props=None,
recursive=False,
onerror=None,
config_files: Optional[Set[str]] = None,
):
if onerror is None:
onerror = onerror_collect
@@ -198,7 +195,6 @@ class Plots:
recursive,
onerror=onerror,
props=props,
config_files=config_files,
):
_resolve_data_sources(data)
result.update(data)
@@ -389,7 +385,7 @@ def _collect_output_plots(repo, targets, props, onerror: Optional[Callable] = No
onerror=onerror,
)
dpath.util.merge(
dpath.merge(
result,
{"": unpacked},
)
@@ -436,16 +432,13 @@ def _resolve_definitions(
props={**plot_props, **props},
onerror=onerror,
)
dpath.util.merge(
dpath.merge(
result,
unpacked,
)
else:
if _matches(targets, config_path, plot_id):
adjusted_props = _adjust_sources(fs, plot_props, config_dir)
dpath.util.merge(
result, {"data": {plot_id: {**adjusted_props, **props}}}
)
elif _matches(targets, config_path, plot_id):
adjusted_props = _adjust_sources(fs, plot_props, config_dir)
dpath.merge(result, {"data": {plot_id: {**adjusted_props, **props}}})
return result
@@ -474,7 +467,7 @@ def _collect_pipeline_files(repo, targets: List[str], props, onerror=None):
dvcfile_defs_dict,
onerror=onerror,
)
dpath.util.merge(
dpath.merge(
result,
{dvcfile_path: resolved},
)
@@ -485,7 +478,6 @@ def _collect_pipeline_files(repo, targets: List[str], props, onerror=None):
def _collect_definitions(
repo: "Repo",
targets=None,
config_files: Optional[Set[str]] = None,
props: Optional[Dict] = None,
onerror: Optional[Callable] = None,
**kwargs,
@@ -497,39 +489,20 @@ def _collect_definitions(
fs = DVCFileSystem(repo=repo)
if not config_files:
dpath.util.merge(
result,
_collect_pipeline_files(repo, targets, props, onerror=onerror),
)
dpath.merge(
result,
_collect_pipeline_files(repo, targets, props, onerror=onerror),
)
if targets or (not targets and not config_files):
dpath.util.merge(
result,
_collect_output_plots(repo, targets, props, onerror=onerror),
)
if config_files:
for path in config_files:
definitions = parse(fs, path).get("data", {})
if definitions:
resolved = _resolve_definitions(
repo.dvcfs,
targets,
props,
path,
definitions,
onerror=onerror,
)
dpath.util.merge(
result,
{path: resolved},
)
dpath.merge(
result,
_collect_output_plots(repo, targets, props, onerror=onerror),
)
for target in targets:
if not result or fs.exists(target):
unpacked = unpack_if_dir(fs, target, props=props, onerror=onerror)
dpath.util.merge(result[""], unpacked)
dpath.merge(result[""], unpacked)
return dict(result)
@@ -584,21 +557,14 @@ def _load_sv(path, fs, delimiter=",", header=True):
with fs.open(path, "r") as fd:
content = fd.read()
first_row = first(csv.reader(io.StringIO(content)))
if header:
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
else:
first_row = first(csv.reader(io.StringIO(content)))
reader = csv.DictReader(
io.StringIO(content),
delimiter=delimiter,
fieldnames=[str(i) for i in range(len(first_row))],
)
fieldnames = reader.fieldnames or []
data = list(reader)
return [
OrderedDict([(field, data_point[field]) for field in fieldnames])
for data_point in data
]
return list(reader)
+3 -1
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
@locked
def pull(
def pull( # noqa: PLR0913
self,
targets=None,
jobs=None,
@@ -25,6 +25,7 @@ def pull(
run_cache=False,
glob=False,
odb: Optional["ObjectDB"] = None,
allow_missing=False,
):
if isinstance(targets, str):
targets = [targets]
@@ -48,6 +49,7 @@ def pull(
with_deps=with_deps,
force=force,
recursive=recursive,
allow_missing=allow_missing,
)
stats["fetched"] = processed_files_count
+7 -4
View File
@@ -1,5 +1,7 @@
from contextlib import suppress
from typing import TYPE_CHECKING, Optional, Sequence
from dvc.config import NoRemoteError
from dvc.exceptions import InvalidArgumentError, UploadError
from dvc.utils import glob_targets
@@ -13,7 +15,7 @@ if TYPE_CHECKING:
@locked
def push( # noqa: C901
def push( # noqa: C901, PLR0913
self,
targets=None,
jobs=None,
@@ -30,9 +32,10 @@ def push( # noqa: C901
include_imports=False,
):
worktree_remote: Optional["Remote"] = None
_remote = self.cloud.get_remote(name=remote)
if _remote.worktree or _remote.fs.version_aware:
worktree_remote = _remote
with suppress(NoRemoteError):
_remote = self.cloud.get_remote(name=remote)
if _remote and (_remote.worktree or _remote.fs.version_aware):
worktree_remote = _remote
pushed = 0
used_run_cache = self.stage_cache.push(remote, odb=odb) if run_cache else []
+6 -6
View File
@@ -129,7 +129,7 @@ def reproduce( # noqa: C901
def _reproduce_stages( # noqa: C901
G, stages, downstream=False, single_item=False, on_unchanged=None, **kwargs
graph, stages, downstream=False, single_item=False, on_unchanged=None, **kwargs
):
r"""Derive the evaluation of the given node for the given graph.
@@ -166,7 +166,7 @@ def _reproduce_stages( # noqa: C901
The derived evaluation of _downstream_ B would be: [B, D, E]
"""
steps = _get_steps(G, stages, downstream, single_item)
steps = _get_steps(graph, stages, downstream, single_item)
force_downstream = kwargs.pop("force_downstream", False)
result = []
@@ -222,16 +222,16 @@ def _reproduce_stages( # noqa: C901
return result
def _get_steps(G, stages, downstream, single_item):
def _get_steps(graph, stages, downstream, single_item):
import networkx as nx
active = G.copy()
active = graph.copy()
if not single_item:
# NOTE: frozen stages don't matter for single_item
for stage in G:
for stage in graph:
if stage.frozen:
# NOTE: disconnect frozen stage from its dependencies
active.remove_edges_from(G.out_edges(stage))
active.remove_edges_from(graph.out_edges(stage))
all_pipelines: List["Stage"] = []
for stage in stages:
+42 -23
View File
@@ -1,9 +1,10 @@
import logging
from functools import partial
from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Set, Tuple, Union, cast
from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Set, Tuple, Union
from funcy import first
from dvc.exceptions import DvcException
from dvc.fs.callbacks import Callback
from dvc.stage.exceptions import StageUpdateError
@@ -16,7 +17,6 @@ if TYPE_CHECKING:
from dvc.stage import Stage
from dvc.types import TargetType
from dvc_data.hashfile.meta import Meta
from dvc_data.hashfile.tree import Tree
from dvc_data.index import DataIndex, DataIndexView
from dvc_objects.fs.base import FileSystem
@@ -28,7 +28,8 @@ logger = logging.getLogger(__name__)
def _meta_checksum(fs: "FileSystem", meta: "Meta") -> Any:
if not meta or meta.isdir:
return meta
return getattr(meta, cast(str, fs.PARAM_CHECKSUM))
assert fs.PARAM_CHECKSUM
return getattr(meta, fs.PARAM_CHECKSUM)
def worktree_view_by_remotes(
@@ -118,12 +119,6 @@ def fetch_worktree(
):
remote_obj = _get_remote(repo, remote_name, remote, "fetch")
index = view.data["repo"]
for key, entry in index.iteritems():
entry.fs = remote_obj.fs
entry.path = remote_obj.fs.path.join(
remote_obj.path,
*key,
)
total = len(index)
with Callback.as_tqdm_callback(
unit="file",
@@ -131,7 +126,7 @@ def fetch_worktree(
disable=total == 0,
) as cb:
cb.set_size(total)
transferred += save(index, callback=cb, jobs=jobs)
transferred += save(index, callback=cb, jobs=jobs, storage="remote")
return transferred
@@ -144,6 +139,7 @@ def push_worktree(
) -> int:
from dvc.repo.index import build_data_index
from dvc_data.index import checkout
from dvc_data.index.checkout import VersioningNotSupported
pushed = 0
stages: Set["Stage"] = set()
@@ -176,17 +172,28 @@ def push_worktree(
disable=total == 0,
) as cb:
cb.set_size(total)
pushed += checkout(
new_index,
remote_obj.path,
remote_obj.fs,
old=old_index,
delete=remote_obj.worktree,
callback=cb,
latest_only=remote_obj.worktree,
jobs=jobs,
**diff_kwargs,
)
try:
pushed += checkout(
new_index,
remote_obj.path,
remote_obj.fs,
old=old_index,
delete=remote_obj.worktree,
callback=cb,
latest_only=remote_obj.worktree,
jobs=jobs,
**diff_kwargs,
)
except VersioningNotSupported:
logger.exception("")
raise DvcException(
f"remote {remote_obj.name!r} does not support versioning"
) from None
if remote_obj.index is not None:
for key, entry in new_index.iteritems():
remote_obj.index[key] = entry
remote_obj.index.commit()
for out in view.outs:
workspace, _key = out.index_key
@@ -209,13 +216,15 @@ def _merge_push_meta(
conflicts (i.e. the DVC output's version ID may not match the pushed/latest
version ID as long when the file content of both versions is the same).
"""
from dvc_data.hashfile.tree import Tree
from dvc_data.index.save import build_tree
_, key = out.index_key
entry = index[key]
repo = out.stage.repo
if out.isdir():
old_tree = cast("Tree", out.get_obj())
old_tree = out.get_obj()
assert isinstance(old_tree, Tree)
entry.hash_info = old_tree.hash_info
entry.meta = out.meta
for subkey, entry in index.iteritems(key):
@@ -349,6 +358,7 @@ def _fetch_out_changes(
update_meta=False,
meta_only=True,
meta_cmp_key=partial(_meta_checksum, remote.fs),
storage="data",
callback=cb,
)
out.save()
@@ -368,6 +378,13 @@ def _get_diff_indexes(
old.add(entry)
for _, entry in remote_index.iteritems(key):
new.add(entry)
for prefix, storage in local_index.storage_map.items():
old.storage_map[prefix] = storage
for prefix, storage in remote_index.storage_map.items():
new.storage_map[prefix] = storage
return old, new
@@ -404,6 +421,7 @@ def _get_update_diff_index(
remote_index: Union["DataIndex", "DataIndexView"],
remote: "Remote",
) -> "DataIndex":
from dvc_data.hashfile.tree import Tree
from dvc_data.index import DataIndex
from dvc_data.index.diff import ADD, MODIFY, UNCHANGED, diff
@@ -423,7 +441,8 @@ def _get_update_diff_index(
if out.isdir():
if not entry.meta.isdir:
fs_path = repo.fs.path.join(repo.root_dir, *entry.key)
tree = cast("Tree", out.obj)
tree = out.obj
assert isinstance(tree, Tree)
_, entry.hash_info = tree.get( # type: ignore[misc]
repo.fs.path.relparts(fs_path, out.fs_path)
)
+1 -1
View File
@@ -74,7 +74,7 @@ def _infos_to_str(infos):
)
def _check_blockers(tmp_dir, lock, info, *, mode, waiters): # noqa: C901
def _check_blockers(tmp_dir, lock, info, *, mode, waiters): # noqa: C901, PLR0912
from .lock import LockError
non_existing_pid = set()
+3 -22
View File
@@ -20,7 +20,6 @@ from scmrepo.noscm import NoSCM
from dvc.exceptions import DvcException
from dvc.progress import Tqdm
from dvc.utils import format_link
if TYPE_CHECKING:
from scmrepo.progress import GitProgressEvent
@@ -58,25 +57,6 @@ class GitAuthError(SCMError):
super().__init__(f"{reason}\n{doc}")
class GitMergeError(SCMError):
def __init__(self, msg: str, scm: Optional["Git"] = None) -> None:
if scm and self._is_shallow(scm):
url = format_link(
"https://dvc.org/doc/user-guide/troubleshooting#git-shallow"
)
msg = (
f"{msg}: `dvc exp` does not work in shallow Git repos. "
f"See {url} for more information."
)
super().__init__(msg)
@staticmethod
def _is_shallow(scm: "Git") -> bool:
if os.path.exists(os.path.join(scm.root_dir, Git.GIT_DIR, "shallow")):
return True
return False
@contextmanager
def map_scm_exception(with_cause: bool = False) -> Iterator[None]:
from scmrepo.exceptions import SCMError as InternalSCMError
@@ -185,7 +165,7 @@ def clone(url: str, to_path: str, **kwargs):
raise CloneError("SCM error") from exc
def resolve_rev(scm: "Git", rev: str) -> str:
def resolve_rev(scm: Union["Git", "NoSCM"], rev: str) -> str:
from scmrepo.exceptions import RevError as InternalRevError
from dvc.repo.experiments.utils import fix_exp_head
@@ -193,6 +173,7 @@ def resolve_rev(scm: "Git", rev: str) -> str:
try:
return scm.resolve_rev(fix_exp_head(scm, rev))
except InternalRevError as exc:
assert isinstance(scm, Git)
# `scm` will only resolve git branch and tag names,
# if rev is not a sha it may be an abbreviated experiment name
if not (rev == "HEAD" or rev.startswith("refs/")):
@@ -227,7 +208,7 @@ def _get_n_commits(scm: "Git", revs: List[str], num: int) -> List[str]:
return results
def iter_revs( # noqa: C901
def iter_revs(
scm: "Git",
revs: Optional[List[str]] = None,
num: int = 1,
+3 -3
View File
@@ -140,7 +140,7 @@ class Stage(params.StageParams):
# pylint:disable=no-value-for-parameter
# rwlocked() confuses pylint
def __init__(
def __init__( # noqa: PLR0913
self,
repo,
path=None,
@@ -545,13 +545,13 @@ class Stage(params.StageParams):
)
@rwlocked(write=["outs"])
def commit(self, allow_missing=False, filter_info=None) -> None:
def commit(self, allow_missing=False, filter_info=None, **kwargs) -> None:
from dvc.output import OutputDoesNotExistError
link_failures = []
for out in self.filter_outs(filter_info):
try:
out.commit(filter_info=filter_info)
out.commit(filter_info=filter_info, **kwargs)
except OutputDoesNotExistError:
if not (allow_missing or out.checkpoint):
raise
+11 -11
View File
@@ -1,6 +1,5 @@
import logging
import os
import tempfile
from contextlib import contextmanager
from typing import TYPE_CHECKING, List, Optional, Tuple
@@ -61,7 +60,7 @@ def _get_stage_hash(stage):
class StageCache:
def __init__(self, repo):
self.repo = repo
self.cache_dir = os.path.join(self.repo.odb.local.path, "runs")
self.cache_dir = os.path.join(self.repo.cache.local.path, "runs")
def _get_cache_dir(self, key):
return os.path.join(self.cache_dir, key[:2], key)
@@ -120,12 +119,12 @@ class StageCache:
@contextmanager
def _cache_type_copy(self):
cache_types = self.repo.odb.local.cache_types
self.repo.odb.local.cache_types = ["copy"]
cache_types = self.repo.cache.local.cache_types
self.repo.cache.local.cache_types = ["copy"]
try:
yield
finally:
self.repo.odb.local.cache_types = cache_types
self.repo.cache.local.cache_types = cache_types
def _uncached_outs(self, stage, cache):
# NOTE: using temporary stage to avoid accidentally modifying original
@@ -167,13 +166,14 @@ class StageCache:
COMPILED_LOCK_FILE_STAGE_SCHEMA(cache)
path = self._get_cache_path(cache_key, cache_value)
parent = self.repo.odb.local.fs.path.parent(path)
self.repo.odb.local.makedirs(parent)
tmp = tempfile.NamedTemporaryFile(delete=False, dir=parent).name
local_fs = self.repo.cache.local.fs
parent = local_fs.path.parent(path)
self.repo.cache.local.makedirs(parent)
tmp = local_fs.path.join(parent, fs.utils.tmp_fname())
assert os.path.exists(parent)
assert os.path.isdir(parent)
dump_yaml(tmp, cache)
self.repo.odb.local.move(tmp, path)
self.repo.cache.local.move(tmp, path)
def restore(self, stage, run_cache=True, pull=False, dry=False):
from .serialize import to_single_stage_lockfile
@@ -259,11 +259,11 @@ class StageCache:
def push(self, remote: Optional[str], odb: Optional["ObjectDB"] = None):
dest_odb = odb or self.repo.cloud.get_remote_odb(remote, "push --run-cache")
return self.transfer(self.repo.odb.local, dest_odb)
return self.transfer(self.repo.cache.local, dest_odb)
def pull(self, remote: Optional[str], odb: Optional["ObjectDB"] = None):
odb = odb or self.repo.cloud.get_remote_odb(remote, "fetch --run-cache")
return self.transfer(odb, self.repo.odb.local)
return self.transfer(odb, self.repo.cache.local)
def get_used_objs(self, used_run_cache, *args, **kwargs):
"""Return used cache for the specified run-cached stages."""
+1 -1
View File
@@ -29,7 +29,7 @@ class MonitorTask:
raise NotImplementedError
@property
def SIGNAL_FILE(self) -> str:
def SIGNAL_FILE(self) -> str: # noqa: N802
raise NotImplementedError
@property
+5 -5
View File
@@ -8,7 +8,6 @@ from typing import (
List,
Optional,
Union,
cast,
no_type_check,
)
@@ -24,7 +23,6 @@ from .utils import resolve_wdir, split_params_deps
if TYPE_CHECKING:
from dvc.stage import PipelineStage, Stage
from dvc_data.hashfile.tree import Tree
PARAM_PARAMS = ParamsDependency.PARAM_PARAMS
PARAM_PATH = ParamsDependency.PARAM_PATH
@@ -154,7 +152,8 @@ def to_pipeline_file(stage: "PipelineStage"):
def to_single_stage_lockfile(stage: "Stage", **kwargs) -> dict:
from dvc.output import split_file_meta_from_cloud
from dvc.output import _serialize_tree_obj_to_files, split_file_meta_from_cloud
from dvc_data.hashfile.tree import Tree
assert stage.cmd
@@ -163,9 +162,10 @@ def to_single_stage_lockfile(stage: "Stage", **kwargs) -> dict:
if item.hash_info.isdir and kwargs.get("with_files"):
obj = item.obj or item.get_obj()
if obj:
obj = cast("Tree", obj)
assert isinstance(obj, Tree)
ret[item.PARAM_FILES] = [
split_file_meta_from_cloud(f) for f in obj.as_list(with_meta=True)
split_file_meta_from_cloud(f)
for f in _serialize_tree_obj_to_files(obj)
]
else:
meta_d = item.meta.to_dict()
+2 -2
View File
@@ -24,7 +24,7 @@ class TestAPI:
dvc.push()
# Remove cache to force download
remove(dvc.odb.local.path)
remove(dvc.cache.local.path)
with api.open("foo") as fobj:
assert fobj.read() == "foo-text"
@@ -62,7 +62,7 @@ class TestAPI:
dvc.push()
if clear_cache:
remove(dvc.odb.repo.path)
remove(dvc.cache.repo.path)
if url := fs_kwargs.get("url"):
fs_kwargs["url"] = url.format(path=tmp_dir, posixpath=tmp_dir.as_posix())
+32 -35
View File
@@ -26,8 +26,6 @@ __all__ = [
"workspace",
"make_workspace",
"local_workspace",
"docker",
"docker_compose",
"docker_compose_project_name",
"docker_services",
]
@@ -183,7 +181,7 @@ def local_remote(make_remote):
@pytest.fixture
def make_workspace(tmp_dir, dvc, make_cloud):
def _make_workspace(name, typ="local"):
from dvc.odbmgr import ODBManager
from dvc.cachemgr import CacheManager
cloud = make_cloud(typ) # pylint: disable=W0621
@@ -197,7 +195,7 @@ def make_workspace(tmp_dir, dvc, make_cloud):
with dvc.config.edit() as conf:
conf["cache"][scheme] = f"{name}-cache"
dvc.odb = ODBManager(dvc)
dvc.cache = CacheManager(dvc)
return cloud
@@ -215,48 +213,47 @@ def local_workspace(make_workspace):
return make_workspace("workspace", typ="local")
@pytest.fixture(scope="session")
def docker():
# See https://travis-ci.community/t/docker-linux-containers-on-windows/301
if os.environ.get("CI") and os.name == "nt":
pytest.skip("disabled for Windows on Github Actions")
try:
subprocess.check_output("docker ps", shell=True)
except (subprocess.CalledProcessError, OSError):
pytest.skip("no docker installed")
@pytest.fixture(scope="session")
def docker_compose(docker): # noqa: ARG001
try:
subprocess.check_output("docker-compose version", shell=True)
except (subprocess.CalledProcessError, OSError):
pytest.skip("no docker-compose installed")
@pytest.fixture(scope="session")
def docker_compose_project_name():
return "pytest-dvc-test"
@pytest.fixture(scope="session")
def docker_services(docker_compose_file, docker_compose_project_name, tmp_path_factory):
# overriding `docker_services` fixture from `pytest_docker` plugin to
# only launch docker images once.
def docker_services(
tmp_path_factory,
docker_compose_command,
docker_compose_file,
docker_compose_project_name,
docker_setup,
):
from filelock import FileLock
# pylint: disable-next=import-error
from pytest_docker.plugin import DockerComposeExecutor, Services
executor = DockerComposeExecutor(docker_compose_file, docker_compose_project_name)
if os.environ.get("CI") and os.name == "nt":
pytest.skip("disabled for Windows on CI")
try:
subprocess.check_output("docker ps", stderr=subprocess.STDOUT, shell=True)
except subprocess.CalledProcessError as err:
out = (err.output or b"").decode("utf-8")
pytest.skip(f"docker is not installed or the daemon is not running: {out}")
try:
cmd = "docker-compose version"
subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True)
except subprocess.CalledProcessError as err:
out = (err.output or b"").decode("utf-8")
pytest.skip(f"docker-compose is not installed: {out}")
executor = DockerComposeExecutor(
docker_compose_command, docker_compose_file, docker_compose_project_name
)
# making sure we don't accidentally launch docker-compose in parallel,
# as it might result in network conflicts. Inspired by:
# https://github.com/pytest-dev/pytest-xdist#making-session-scoped-fixtures-execute-only-once
lockfile = tmp_path_factory.getbasetemp().parent / "docker-compose.lock"
with FileLock(str(lockfile)): # pylint:disable=abstract-class-instantiated
executor.execute("up --build -d")
return Services(executor)
with FileLock(os.fspath(lockfile)):
executor.execute(docker_setup)
# note: we are not tearing down the containers here
return Services(executor)
+69 -16
View File
@@ -10,6 +10,12 @@ from dvc_data.hashfile.tree import Tree
# pylint: disable=unused-argument
def _check_status(status, **kwargs):
for key in ("ok", "missing", "new", "deleted"):
expected = kwargs.get(key, set())
assert expected == set(getattr(status, key))
class TestRemote:
def test(self, tmp_dir, dvc, remote): # pylint: disable=W0613
(stage,) = tmp_dir.dvc_gen("foo", "foo")
@@ -33,11 +39,6 @@ class TestRemote:
dir_hash = out_dir.hash_info
dir_hashes = {dir_hash} | {oid for _, _, oid in out_dir.obj}
def _check_status(status, **kwargs):
for key in ("ok", "missing", "new", "deleted"):
expected = kwargs.get(key, set())
assert expected == set(getattr(status, key))
# Check status
status = dvc.cloud.status(foo_hashes)
_check_status(status, new={foo_hash})
@@ -47,8 +48,8 @@ class TestRemote:
# Move cache and check status
# See issue https://github.com/iterative/dvc/issues/4383 for details
backup_dir = dvc.odb.local.path + ".backup"
shutil.move(dvc.odb.local.path, backup_dir)
backup_dir = dvc.cache.local.path + ".backup"
shutil.move(dvc.cache.local.path, backup_dir)
status = dvc.cloud.status(foo_hashes)
_check_status(status, missing={foo_hash})
@@ -56,8 +57,8 @@ class TestRemote:
_check_status(status_dir, missing=dir_hashes)
# Restore original cache:
remove(dvc.odb.local.path)
shutil.move(backup_dir, dvc.odb.local.path)
remove(dvc.cache.local.path)
shutil.move(backup_dir, dvc.cache.local.path)
# Push and check status
dvc.cloud.push(foo_hashes)
@@ -74,7 +75,7 @@ class TestRemote:
_check_status(status_dir, ok=dir_hashes)
# Remove and check status
dvc.odb.local.clear()
dvc.cache.local.clear()
status = dvc.cloud.status(foo_hashes)
_check_status(status, deleted={foo_hash})
@@ -114,6 +115,58 @@ class TestRemote:
dvc.pull(run_cache=True)
assert list(stage_cache_dir.rglob("*")) == expected
@pytest.mark.xfail(raises=NotImplementedError, strict=False)
def test_pull_00_prefix(self, tmp_dir, dvc, remote, monkeypatch):
# Related: https://github.com/iterative/dvc/issues/6089
fs_type = type(dvc.cloud.get_remote_odb("upstream").fs)
monkeypatch.setattr(fs_type, "_ALWAYS_TRAVERSE", True, raising=False)
monkeypatch.setattr(fs_type, "LIST_OBJECT_PAGE_SIZE", 256, raising=False)
# foo's md5 checksum is 00411460f7c92d2124a67ea0f4cb5f85
# bar's md5 checksum is 0000000018e6137ac2caab16074784a6
foo_out = tmp_dir.dvc_gen("foo", "363")[0].outs[0]
bar_out = tmp_dir.dvc_gen("bar", "jk8ssl")[0].outs[0]
expected_hashes = {foo_out.hash_info, bar_out.hash_info}
dvc.push()
status = dvc.cloud.status(expected_hashes)
_check_status(status, ok=expected_hashes)
dvc.cache.local.clear()
remove(tmp_dir / "foo")
remove(tmp_dir / "bar")
stats = dvc.pull()
assert stats["fetched"] == 2
assert set(stats["added"]) == {"foo", "bar"}
@pytest.mark.xfail(raises=NotImplementedError, strict=False)
def test_pull_no_00_prefix(self, tmp_dir, dvc, remote, monkeypatch):
# Related: https://github.com/iterative/dvc/issues/6244
fs_type = type(dvc.cloud.get_remote_odb("upstream").fs)
monkeypatch.setattr(fs_type, "_ALWAYS_TRAVERSE", True, raising=False)
monkeypatch.setattr(fs_type, "LIST_OBJECT_PAGE_SIZE", 256, raising=False)
# foo's md5 checksum is 14ffd92a6cbf5f2f657067df0d5881a6
# bar's md5 checksum is 64020400f00960c0ef04052547b134b3
foo_out = tmp_dir.dvc_gen("foo", "dvc")[0].outs[0]
bar_out = tmp_dir.dvc_gen("bar", "cml")[0].outs[0]
expected_hashes = {foo_out.hash_info, bar_out.hash_info}
dvc.push()
status = dvc.cloud.status(expected_hashes)
_check_status(status, ok=expected_hashes)
dvc.cache.local.clear()
remove(tmp_dir / "foo")
remove(tmp_dir / "bar")
stats = dvc.pull()
assert stats["fetched"] == 2
assert set(stats["added"]) == {"foo", "bar"}
class TestRemoteVersionAware:
def test_file(self, tmp_dir, dvc, remote_version_aware): # pylint: disable=W0613
@@ -125,7 +178,7 @@ class TestRemoteVersionAware:
out = stage.outs[0]
assert out.meta.version_id
remove(dvc.odb.local.path)
remove(dvc.cache.local.path)
remove(tmp_dir / "foo")
dvc.pull()
@@ -152,7 +205,7 @@ class TestRemoteVersionAware:
assert file["version_id"]
assert file["remote"] == "upstream"
remove(dvc.odb.local.path)
remove(dvc.cache.local.path)
remove(tmp_dir / "data_dir")
dvc.pull()
@@ -172,7 +225,7 @@ class TestRemoteWorktree:
out = stage.outs[0]
assert out.meta.version_id
remove(dvc.odb.local.path)
remove(dvc.cache.local.path)
remove(tmp_dir / "foo")
dvc.pull()
@@ -199,7 +252,7 @@ class TestRemoteWorktree:
assert file["version_id"]
assert file["remote"] == "upstream"
remove(dvc.odb.local.path)
remove(dvc.cache.local.path)
remove(tmp_dir / "data_dir")
dvc.pull()
@@ -232,7 +285,7 @@ class TestRemoteWorktree:
tmp_dir.scm_add([tmp_dir / "data_dir.dvc"], commit="v2")
assert not (remote_worktree / "data_dir" / "data").exists()
remove(dvc.odb.local.path)
remove(dvc.cache.local.path)
remove(tmp_dir / "data_dir")
# pulling the original pushed version should still succeed
scm.checkout(v1)
@@ -282,7 +335,7 @@ class TestRemoteWorktree:
assert (tmp_dir / "data_dir" / "data").read_text() == "modified"
assert (tmp_dir / "data_dir" / "new_data").read_text() == "new data"
remove(dvc.odb.local.path)
remove(dvc.cache.local.path)
remove(tmp_dir / "foo")
remove(tmp_dir / "data_dir")
dvc.pull()
+98 -6
View File
@@ -27,7 +27,7 @@ class TestImport:
pytest.skip()
def test_import_dir(self, tmp_dir, dvc, workspace, stage_md5, dir_md5):
from dvc.odbmgr import ODBManager
from dvc.cachemgr import CacheManager
workspace.gen({"dir": {"file": "file", "subdir": {"subfile": "subfile"}}})
@@ -35,7 +35,7 @@ class TestImport:
# to import dirs
with dvc.config.edit() as conf:
del conf["cache"]
dvc.odb = ODBManager(dvc)
dvc.cache = CacheManager(dvc)
assert not (tmp_dir / "dir").exists() # sanity check
dvc.imp_url("remote://workspace/dir")
@@ -93,7 +93,7 @@ class TestImportURLVersionAware:
assert (tmp_dir / "file").read_text() == "file"
assert dvc.status() == {}
dvc.odb.local.clear()
dvc.cache.local.clear()
remove(tmp_dir / "file")
dvc.pull()
assert (tmp_dir / "file").read_text() == "file"
@@ -106,7 +106,7 @@ class TestImportURLVersionAware:
assert (tmp_dir / "file").read_text() == "modified"
assert dvc.status() == {}
dvc.odb.local.clear()
dvc.cache.local.clear()
remove(tmp_dir / "file")
dvc.pull()
assert (tmp_dir / "file").read_text() == "modified"
@@ -119,7 +119,7 @@ class TestImportURLVersionAware:
assert (tmp_dir / "data_dir" / "subdir" / "file").read_text() == "file"
assert dvc.status() == {}
dvc.odb.local.clear()
dvc.cache.local.clear()
remove(tmp_dir / "data_dir")
dvc.pull()
assert (tmp_dir / "data_dir" / "subdir" / "file").read_text() == "file"
@@ -134,12 +134,22 @@ class TestImportURLVersionAware:
assert (tmp_dir / "data_dir" / "new_file").read_text() == "new"
assert dvc.status() == {}
dvc.odb.local.clear()
dvc.cache.local.clear()
remove(tmp_dir / "data_dir")
dvc.pull()
assert (tmp_dir / "data_dir" / "subdir" / "file").read_text() == "modified"
assert (tmp_dir / "data_dir" / "new_file").read_text() == "new"
def test_import_no_download(self, tmp_dir, dvc, remote_version_aware):
remote_version_aware.gen({"data_dir": {"subdir": {"file": "file"}}})
dvc.imp_url("remote://upstream/data_dir", version_aware=True, no_download=True)
stage = first(dvc.index.stages)
assert not stage.outs[0].can_push
dvc.pull()
assert (tmp_dir / "data_dir" / "subdir" / "file").read_text() == "file"
assert dvc.status() == {}
class TestAdd:
@pytest.fixture
@@ -272,3 +282,85 @@ class TestGetUrl:
def test_get_url_nonexistent(self, cloud):
with pytest.raises(URLMissingError):
Repo.get_url(str(cloud / "nonexistent"), config=cloud.config)
class TestToRemote:
def test_add_to_remote(self, tmp_dir, dvc, remote, workspace):
workspace.gen("foo", "foo")
url = "remote://workspace/foo"
[stage] = dvc.add(url, to_remote=True)
assert not (tmp_dir / "foo").exists()
assert (tmp_dir / "foo.dvc").exists()
assert len(stage.deps) == 0
assert len(stage.outs) == 1
hash_info = stage.outs[0].hash_info
meta = stage.outs[0].meta
assert hash_info.name == "md5"
assert hash_info.value == "acbd18db4cc2f85cedef654fccc4a4d8"
assert (remote / "ac" / "bd18db4cc2f85cedef654fccc4a4d8").read_text() == "foo"
assert meta.size == len("foo")
def test_import_url_to_remote_file(self, tmp_dir, dvc, workspace, remote):
workspace.gen("foo", "foo")
url = "remote://workspace/foo"
stage = dvc.imp_url(url, to_remote=True)
assert stage.deps[0].hash_info.value is not None
assert not (tmp_dir / "foo").exists()
assert (tmp_dir / "foo.dvc").exists()
assert len(stage.deps) == 1
assert stage.deps[0].def_path == url
assert len(stage.outs) == 1
hash_info = stage.outs[0].hash_info
assert hash_info.name == "md5"
assert hash_info.value == "acbd18db4cc2f85cedef654fccc4a4d8"
assert (remote / "ac" / "bd18db4cc2f85cedef654fccc4a4d8").read_text() == "foo"
assert stage.outs[0].meta.size == len("foo")
def test_import_url_to_remote_dir(self, tmp_dir, dvc, workspace, remote):
import json
workspace.gen(
{
"data": {
"foo": "foo",
"bar": "bar",
"sub_dir": {"baz": "sub_dir/baz"},
}
}
)
url = "remote://workspace/data"
stage = dvc.imp_url(url, to_remote=True)
assert not (tmp_dir / "data").exists()
assert (tmp_dir / "data.dvc").exists()
assert len(stage.deps) == 1
assert stage.deps[0].def_path == url
assert len(stage.outs) == 1
hash_info = stage.outs[0].hash_info
assert hash_info.name == "md5"
assert hash_info.value == "55d05978954d1b2cd7b06aedda9b9e43.dir"
file_parts = json.loads(
(remote / "55" / "d05978954d1b2cd7b06aedda9b9e43.dir").read_text()
)
assert len(file_parts) == 3
assert {file_part["relpath"] for file_part in file_parts} == {
"foo",
"bar",
"sub_dir/baz",
}
for file_part in file_parts:
md5 = file_part["md5"]
assert (remote / md5[:2] / md5[2:]).read_text() == file_part["relpath"]

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