Compare commits

...
210 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 3e183d62ed deps: bump dvc-* plugins to 2.19.0
These are the same packages but with correct versions to sync up with existing packages that we generated in conda over the years.
2022-08-25 14:44:13 +03:00
David de la Iglesia Castro e815bbe211 metrics: show: Fix show from subdir.
This was a regression introduced in #7712.
Calling `repo.metrics.show` from a subdir stopped returning metrics because `fs.isfile(metric)` check returned false.
2022-08-25 13:02:22 +02:00
Saugat Pachhai (सौगात) 2374c72105 status: add unknown state 2022-08-25 13:23:18 +03:00
Saugat Pachhai (सौगात) 5cf1421c61 fix tests 2022-08-25 13:23:18 +03:00
skshetry a6a2feea62 Update setup.cfg 2022-08-25 13:23:18 +03:00
Saugat Pachhai (सौगात) 6c1d00c2d9 fix diff when no object exists by falling back to shallow_diff 2022-08-25 13:23:18 +03:00
Saugat Pachhai (सौगात) f72456f67c add shallow_diff 2022-08-25 13:23:18 +03:00
skshetry 2c556f58d3 data status: fix not-in-cache hint (#8181)
See https://github.com/iterative/dvc.org/pull/3812\#discussion_r936625617.
2022-08-25 15:39:59 +05:45
Ruslan Kuprieiev 460d08057c Create hook-fsspec.py 2022-08-24 23:39:46 +03:00
Ronan Lamy cc6deb24c8 fs: remove obsolete special cases 2022-08-23 23:20:42 +03:00
Ronan Lamy e681030c72 fs: simplify filtering out dvcfiles 2022-08-23 23:20:42 +03:00
Ronan Lamy 7a51548214 fs: Add fast path for the no subrepo case in _get_fs_pair() 2022-08-23 23:20:42 +03:00
Ronan Lamy 8e99fd0091 ls: Use fs.walk(..., detail=True) instead of separate call to fs.info() 2022-08-23 23:20:42 +03:00
Ronan Lamy 7e5a586f85 fs: Remove redundant .isdvc() check
fs.walk(..., dvc_only=True) already outputs dvc objects only, so
fs.isdvc(path, recursive=True) is always True.
2022-08-23 23:20:42 +03:00
Ronan Lamy c76a84b7b1 fs: remove one call to ._get_key() in _DvcFS._get_fs_pair() 2022-08-23 23:20:42 +03:00
Daniele Trifirò 26008f1559 import-url/update: add --no-download flag
add --no-download flag to dvc import-url/dvc update to only create/update .dvc
files without downloading the associated data.

Created .dvc files can be fetched using `dvc pull` or can be
updated using `dvc update --no-download`.
2022-08-23 23:07:01 +03:00
Daniele Trifirò 92d680ff94 tests: fix typo in test name 2022-08-23 23:07:01 +03:00
Daniele Trifirò c69c46d08c fix error message on fetch/pull/checkout failure 2022-08-23 23:07:01 +03:00
skshetry 6477ade557 build: skip install fpm and ruby on Windows (#8169) 2022-08-23 18:02:47 +05:45
skshetry 78dd045d29 ci: drop __token__, it's the default (#8157) 2022-08-22 08:40:31 +05:45
skshetry 2ddbbdf5a4 ci: refactor binary executable building workflow (#8156)
This now uses the matrix to build executables from same jobs definition.
2022-08-22 08:38:58 +05:45
skshetry 17a565023b build: bump ruby to 2.6 (#8154) 2022-08-20 23:32:23 +05:45
skshetry b5d5728bf8 deps: bump dvc-data to 0.1.22 and dvc-s3 to 0.0.2 (#8153)
* deps: bump dvc-data to 0.1.22

* deps: bump dvc-s3 to 0.0.2
2022-08-20 22:59:40 +05:45
skshetry 055641a51e deps: bump dvc-data to 0.1.21 (#8147) 2022-08-19 13:22:56 +05:45
Ruslan Kuprieiev fc509d4e4a Delete hook-googleapiclient.model.py
This is already provided by pyinstaller's standard hooks https://github.com/pyinstaller/pyinstaller-hooks-contrib/blob/master/src/_pyinstaller_hooks_contrib/hooks/stdhooks/hook-googleapiclient.model.py
2022-08-18 21:07:57 +03:00
Ruslan Kuprieiev a67fea2235 doctor: ignore missing plugins 2022-08-18 21:02:41 +03:00
skshetry f062b52e05 Add python version label to the readme (#8146) 2022-08-18 18:34:58 +05:45
Paweł Redzyński 25166f3e02 plots: show-vega: adjust renderers before show vega
Fixes: iterative/cml#1112
2022-08-18 12:52:52 +02:00
Ruslan Kuprieiev a79ea15827 tests: webhdfs: fix linter 2022-08-18 12:43:12 +03:00
Ruslan Kuprieiev 8db9a2f70f deps: bump dvc-data to 0.1.19 2022-08-18 12:43:12 +03:00
David de la Iglesia Castro ff2f2e433f pyinstaller: Restrict to 5.0
5.2 didn't fix https://github.com/iterative/dvc/issues/7949
2022-08-18 12:42:11 +03:00
Ruslan Kuprieiev 3fc9f323a7 fs: don't import SSHFileSystem 2022-08-18 00:23:40 +03:00
Ruslan Kuprieiev bf0aa5e8aa repo: data: ignore pylint false-positives 2022-08-17 23:02:22 +03:00
Ruslan Kuprieiev 3b3866323e deps: bump dvc-data to 0.1.18 2022-08-17 23:02:22 +03:00
Ruslan Kuprieiev 3c3cbee7fd deps: bump dvc-http to 0.0.2 2022-08-16 22:31:05 +03:00
Ruslan Kuprieiev 4a95020155 gs: add endpointurl 2022-08-14 13:39:03 +03:00
Ruslan Kuprieiev 3a429a869e tests: lockfile: use fs_path 2022-08-13 23:09:21 +03:00
Ruslan Kuprieiev 99465b25b5 deps: bump dvc-data to 0.1.17 2022-08-13 22:43:46 +03:00
Ruslan Kuprieiev fcdad4d229 deps: bump dvc-data to 0.1.16 2022-08-13 22:25:24 +03:00
Ruslan Kuprieiev 2e8b463795 deps: bump dvc-data 2022-08-12 11:58:44 +03:00
Ivan Shcheklein 206e00ccbe guard rwlock file to prevent not locked repo edits 2022-08-11 15:54:42 +03:00
Peter Rowlands f13443477d exp run: account for repo root when staging git-deps 2022-08-11 13:48:28 +09:00
Dave Berenbaum 9837747eef exp run: ui tweak (#8124) 2022-08-11 09:14:43 +05:45
dependabot[bot] cf5dfc517a build(deps-dev): Bump filelock from 3.7.1 to 3.8.0 (#8125)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.7.1 to 3.8.0.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/py-filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.7.1...3.8.0)

---
updated-dependencies:
- dependency-name: filelock
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-08-11 09:14:12 +05:45
dependabot[bot] c134d4ab8e build(deps): Bump dvc-data from 0.1.13 to 0.1.14 (#8117)
Bumps [dvc-data](https://github.com/iterative/dvc-data) from 0.1.13 to 0.1.14.
- [Release notes](https://github.com/iterative/dvc-data/releases)
- [Commits](https://github.com/iterative/dvc-data/compare/0.1.13...0.1.14)

---
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>
2022-08-10 09:17:12 +05:45
skshetry 7a0b3e9377 Update tests/func/experiments/test_queue.py 2022-08-09 17:50:48 +08:00
karajan1001 d6f8e5a507 Add flaky to a queue related test.
This is a flaky, the queue tasks status might remain "Running" even if
it had already been completed, beside sometimes I also noticed it raise
a pytest fixture error.
2022-08-09 17:50:48 +08:00
Alex HallandDavid de la Iglesia Castro 2ca1d6c7c1 refactor: extract load_path function from common code using LOADERS (#7983)
Following up on https://github.com/iterative/dvc/pull/7965#discussion_r912684074

Co-authored-by: David de la Iglesia Castro <[email protected]>
2022-08-09 09:09:43 +00:00
Ivan Shcheklein f7fbac1eed stage add: fix hint message consistency (#8112) 2022-08-09 11:18:37 +05:45
skshetry 7c1e339650 data status: handle path joining properly for tree leaves/items (#8107) 2022-08-09 09:34:15 +05:45
Ivan Shcheklein b7c0c41262 lock test name fix 2022-08-09 00:47:04 +03:00
karajan1001 9180a718c1 queue status: show final experiment name
fix: #8059

1. showing final experiment name in `queue status` for successful tasks.
2. Add a unit test for `celery_queue.status`
2022-08-08 15:49:51 +09:00
karajan1001 306456d453 queue status: ERROR: Invalid experiment
fix: #8014
> ERROR: Invalid experiment '{entry.stash_rev[:7]}'.

This happens when the queue task failed from a scm error.

{"exc_type": "GitMergeError", "exc_message": ["Cannot fast-forward HEAD
to '05100047a341f2fa4a02421289d48f84d8c45e86'"], "exc_module": "dvc.scm"}.

And the failed task neither create a infofile nor create a fail_stash.

1. Didn't raise DvcException if no infofile found for failed tasks.
   (success tasks still raise it)
2. Add a new unit test for this.
2022-08-08 15:07:11 +09:00
Ivan Shcheklein 95421a5605 fix logic for unlock on unlocked lock calls 2022-08-08 14:25:10 +09:00
David de la Iglesia Castro 31635b8312 exp run: Use hydra for parsing --set-param.
- Overriding a config value : foo.bar=value
- Appending a config value : +foo.bar=value
- Appending or overriding a config value : ++foo.bar=value
- Removing a config value : ~foo.bar, ~foo.bar=value

See https://hydra.cc/docs/advanced/override_grammar/basic/#modifying-the-config-object

---

Breaking changes:

To modify a list, `foo[0]=bar` must now be passed as `foo.0=bar`.

Modifying a nested list inside a dictionary is not supported by omegaconf.

---

Closes #4883
Closes #5868
Closes #6129
2022-08-05 17:53:24 +02:00
David de la Iglesia Castro 45ac64ddde api: Fix params_show when stage has no params
Also fix when `deps=True` and no params in the entire pipeline.

Closes #8064
2022-08-05 16:26:19 +03:00
Sami Abdul-Wahid c7b22b467c Upgrade from toml to tomlkit library (#7468)
* Upgrade from toml to tomlkit library
2022-08-05 18:58:22 +05:45
karajan1001 ae83e3c787 Now exp remove can clean up failed tasks and associated successful tasks
fix: #8018
fix: #8071

1. Add one line to make `exp remove` can remove failed tasks, and will
   also delete associated successful tasks
2. Add a new unit test for this behaviour.
3. Solve the unstable test_queue problem.
2022-08-05 16:09:51 +09:00
karajan1001andPeter Rowlands f68c4e9a4b Refactor dvc.repo.experiment.remove to make matching exp by name also
return its queue entry info.

Some review changes.

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2022-08-05 16:09:51 +09:00
karajan1001 2182c4508d Make exp remove flags and arguments mutually exclusive.
1. Current behavior didn't follow the either or description.
2. Make the underlying logic more clear.
2022-08-05 16:09:51 +09:00
karajan1001 eaab253e2b Refactor dvc.repo.experiments.queue.remove.py
Make a better API for other place to call.
2022-08-05 16:09:51 +09:00
karajan1001 905c416ffc Decouple queue clear and remove commands 2022-08-05 16:09:51 +09:00
karajan1001 a6063307b5 Replace the _clear_queue function with celery_queue.clear 2022-08-05 16:09:51 +09:00
Ruslan Kuprieiev 704191b15a output: resolve relative to stage.wdir only on the same fs
Leftover from https://github.com/iterative/dvc/pull/8091
2022-08-04 19:25:31 +03:00
dependabot[bot] 5625a94365 build(deps): Bump dvc-data from 0.1.12 to 0.1.13
Bumps [dvc-data](https://github.com/iterative/dvc-data) from 0.1.12 to 0.1.13.
- [Release notes](https://github.com/iterative/dvc-data/releases)
- [Commits](https://github.com/iterative/dvc-data/compare/0.1.12...0.1.13)

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

Signed-off-by: dependabot[bot] <[email protected]>
2022-08-04 20:26:18 +05:45
dependabot[bot] 634894d3b1 build(deps): Bump dvc-data from 0.1.11 to 0.1.12
Bumps [dvc-data](https://github.com/iterative/dvc-data) from 0.1.11 to 0.1.12.
- [Release notes](https://github.com/iterative/dvc-data/releases)
- [Commits](https://github.com/iterative/dvc-data/compare/0.1.11...0.1.12)

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

Signed-off-by: dependabot[bot] <[email protected]>
2022-08-04 13:22:48 +03:00
Ruslan Kuprieiev 0ce4e769f1 repo: make lock/rwlock kinda work on non local fs 2022-08-03 21:10:28 +03:00
Ruslan Kuprieiev 2c3655f7ff repo: use fs.makedirs instead of os.makedirs 2022-08-03 21:10:28 +03:00
Ruslan Kuprieiev 52bdd90840 repo: support basic workflow on s3fs/adlfs/etc 2022-08-03 21:10:28 +03:00
Ruslan Kuprieiev fcc7240617 odb: use odb.path instead of odb.cache_dir 2022-08-03 21:10:28 +03:00
Ruslan Kuprieiev ecd98bdf7d odbmgr: separate repo and local odbs 2022-08-03 21:10:28 +03:00
Ruslan Kuprieiev e525d9bedb deps: bump dvc-data to 0.1.11 2022-08-03 21:10:28 +03:00
Yury 27b2a5cd6d revert logger to lazy formatting 2022-08-03 13:18:10 +03:00
Peter Rowlands 8518fe639f dvc-task: update to 0.1.2 2022-08-03 16:44:36 +09:00
Ruslan Kuprieiev 496599518a deps: bump dvc-data to 0.1.9 2022-08-02 23:57:34 +03:00
Yury 116ad512e4 plots: remove legacy code from verify_metrics
Fixes: #7988
2022-08-02 14:38:29 +09:00
Ruslan Kuprieiev 6a7b55fdf7 datafs: use dvc-data's datafs 2022-08-02 03:13:06 +03:00
Paweł Redzyński ed62ddb373 cmd: plots: update messaging
Related: iterative/dvc.org#3691
2022-08-01 17:29:13 +02:00
Peter Rowlands 38b1a41f2b build: add pyinstaller hooks for dvc-task and celery 2022-08-01 12:26:53 +03:00
skshetry 6f0736142b deps: bump dvc-data to 0.1.6 2022-07-28 21:45:23 +05:45
Gao 737d7cda4b queue: missing f format for the error message. 2022-07-28 21:15:51 +08:00
dberenbaum 75745a70b4 readme: add exps to quickstart 2022-07-28 10:16:32 +02:00
Ivan Shcheklein 9ff18502b6 add gdrive remote profile field to config 2022-07-27 23:29:18 +03:00
Paweł Redzyński 44a4fb59a7 plots: account for wdir in stage plots
Fixes: #8003
2022-07-26 13:59:24 +02:00
Paweł Redzyński 6543683c0c project: update dvc render and dvclive test dependencies 2022-07-26 13:40:59 +02:00
pre-commit-ci[bot] 8fee26c3d2 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-07-26 15:02:09 +05:45
Gaoandskshetry 7c8994b538 Update dvc/repo/experiments/queue/workspace.py
Co-authored-by: skshetry <[email protected]>
2022-07-26 15:02:09 +05:45
karajan1001 9dfe88e2b1 exp run: workspace status wrong
fix: #8045
2022-07-26 15:02:09 +05:45
Saugat Pachhai (सौगात) d0eda1d538 data status: add spinners during diff 2022-07-25 19:32:19 +05:45
Saugat Pachhai (सौगात) 896acedb07 data status: optimize printing status list
`--untracked-files` and `--unchanged` coupled with `--granular`
puts a lot of stress on `show_status`. Currently, with those options,
dvc spends ~26s on `_show_status` function.

Unhappy with the above result, I looked into improving the situation with
`console.out`. It did reduce that to 9s but it still felt like a lot.

Then I dropped to good old colorama, which dropped to 3s(term)/1.24(redirected).
I tested with buffering output which dropped this further to
~600ms(term)/~100ms(redirected).

This may seem like premature optimization, but this was preventing me
from figuring out perf issues in granular outputs, and I believe the
user should not be penalize for using feature that we already offer.
2022-07-25 17:47:23 +05:45
Saugat Pachhai (सौगात) fd7dcae8aa cli/log: handle SIGPIPE errors
`dvc <command> | head` for example could fail with SIGPIPE and
would print a traceback with BrokenPipeError exception. Now we
redirect do /dev/null in case of error as suggested in Python's
docs: https://docs.python.org/3/library/signal.html\#note-on-sigpipe.
2022-07-25 16:38:09 +05:45
karajan1001 a4763593c7 queue: clean local EXEC_HEAD & EXEC_BASELINE after we run exp with queue.
fix: #8031

Currently when we use tmp directory to run a experiment, we set
`EXEC_HEAD` , `EXEC_BASELINE` and `EXEC_MERGE` heads locally but forget
to remove it. This cause some problem when we use `exp show`

1. Clean `EXEC_HEAD` , `EXEC_BASELINE` and `EXEC_MERGE` after we pushed
   them to tmp_dir
2. Add a unit test for it.

Co-authored-by:
2022-07-25 18:17:12 +08:00
dberenbaum cc2d2495c0 readme: link to docs for related tech 2022-07-22 23:31:59 +02:00
Jorge Orpinel 9b30ef474a exp branch: update -h stdout
per https://github.com/iterative/dvc.org/pull/3780#pullrequestreview-1043024878
2022-07-22 23:30:14 +03:00
David de la Iglesia Castro f95e6340e0 utils: remove dead code 2022-07-22 16:30:51 +02:00
Saugat Pachhai (सौगात) c3ac4f84bf data status: fix untracked files output for subdir 2022-07-22 16:29:04 +03:00
Saugat Pachhai (सौगात) 61729ff66a use M fixture for concise tests 2022-07-22 16:29:04 +03:00
Saugat Pachhai (सौगात) 3dfcb82e5c add tests for data:status command 2022-07-22 16:29:04 +03:00
Saugat Pachhai (सौगात) 340a158595 introduce new dvc data status command 2022-07-22 16:29:04 +03:00
Ruslan Kuprieiev 4b965c3f9f fs: gdrive: add gdrive_acknowledge_abuse
Fixes https://github.com/iterative/dvc/issues/7832
2022-07-22 14:58:45 +03:00
dependabot[bot] b5220129e7 build(deps): Bump pyinstaller from 5.0 to 5.2
Bumps [pyinstaller](https://github.com/pyinstaller/pyinstaller) from 5.0 to 5.2.
- [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.0...v5.2)

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

Signed-off-by: dependabot[bot] <[email protected]>
2022-07-22 14:56:13 +03:00
Ruslan Kuprieiev 7a4886386f deps: bump dvc-data to 0.1.4 2022-07-21 22:53:26 +03:00
Ruslan Kuprieiev ae143505e4 deps: bump dvc-data to 0.1.3 2022-07-21 19:44:38 +03:00
Ruslan Kuprieiev b9b2e5bbd9 Delete test_gdrive.py
Moved to dvc-gdrive.
2022-07-21 19:34:04 +03:00
Ruslan Kuprieiev 66420439c9 deps: bump dvc-data to 0.1.2 2022-07-21 19:16:21 +03:00
David de la Iglesia Castro ed1d8e2b8f exp: init: Use persist: true if checkpoint. 2022-07-21 13:21:06 +02:00
dberenbaum 0b07354361 readme: drop table of contents
readme: top-level links in comments
2022-07-21 09:01:17 +02:00
dependabot[bot] fa200c9b4c build(deps-dev): Bump pylint from 2.14.4 to 2.14.5
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.14.4 to 2.14.5.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.14.4...v2.14.5)

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

Signed-off-by: dependabot[bot] <[email protected]>
2022-07-20 10:04:44 +05:45
dependabot[bot] 1aaf836340 build(deps-dev): bump mypy from 0.961 to 0.971
Bumps [mypy](https://github.com/python/mypy) from 0.961 to 0.971.
- [Release notes](https://github.com/python/mypy/releases)
- [Commits](https://github.com/python/mypy/compare/v0.961...v0.971)

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

Signed-off-by: dependabot[bot] <[email protected]>
2022-07-20 10:36:20 +08:00
Jorge Orpinel Perez f51e96fe6d Revert "readme: roll back non-vscode changes"
This reverts commit 5482de3582e366b86fb4923ba95e72cc39a2e37e.
2022-07-20 07:16:26 +05:45
Jorge Orpinel Perez afefc0ddc1 readme: add paragraph about dvc exp, add GS links 2022-07-20 07:16:26 +05:45
Jorge Orpinel Perez bd82af2e1e readme: better Studio mention 2022-07-20 07:16:26 +05:45
Jorge Orpinel Perez 34f20bf69f readme: 1 sentence = 1 line formatting 2022-07-20 07:16:26 +05:45
Jorge Orpinel Perez 2c339a7fb3 readme: move How it works after Quick started sections 2022-07-20 07:16:26 +05:45
Jorge Orpinel Perez ec4d61e2b1 readme: improve and reformat How it works 2022-07-20 07:16:26 +05:45
Ruslan Kuprieiev 94eec50d27 deps: bump dvc-data to 0.1.1 2022-07-19 14:55:28 +03:00
Ruslan Kuprieiev dd32ff6206 deps: bump dvc-data to 0.0.31 2022-07-19 04:54:34 +03:00
Ruslan Kuprieiev 52a9c58cc0 deps: bump dvc-data to 0.0.30 2022-07-19 04:36:41 +03:00
Ruslan Kuprieiev 01855b7dcf deps: bump dvc-data to 0.0.28 2022-07-18 21:46:22 +03:00
Ruslan Kuprieiev 38f10884a7 deps: bump dvc-data to 0.0.27 2022-07-18 19:15:02 +03:00
Ruslan Kuprieiev 9e6d2163df deps: bump dvc-data to 0.0.26 2022-07-18 01:03:06 +03:00
skshetry dbfe88510f deps: bump dvc-data to 0.0.24 2022-07-15 22:40:26 +05:45
Alexander Guschin c04eb40f90 Update setup.cfg 2022-07-14 18:12:47 +05:45
David de la Iglesia Castro c2668110b7 gc: Add --rev and --num.
Closes #7695
2022-07-12 20:13:07 +02:00
Peter Rowlands 80272a0770 queue: cleanup flaky tests 2022-07-12 21:39:49 +09:00
Peter Rowlands 5a6e363c16 queue: cleanup help/usage to match docs 2022-07-12 21:39:49 +09:00
Peter Rowlands ed593493df queue start: simply jobs validation 2022-07-12 21:39:49 +09:00
Peter Rowlands 3130a9a75f queue status: use summary style output for worker status 2022-07-12 21:39:49 +09:00
Peter Rowlands eeb516e7d3 queue: disable dvc-machine handling for queue 2022-07-12 21:39:49 +09:00
karajan1001 c0368e1756 Refactor: Move remove_revs from BaseStashQueue to expstash 2022-07-12 21:39:49 +09:00
GaoandPeter Rowlands e19824fd2e Update dvc/repo/experiments/queue/base.py dvc/repo/experiments/queue/remove.py
Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2022-07-12 21:39:49 +09:00
karajan1001 02681f2e65 Rename _all to all_ remove some tricky func test. 2022-07-12 21:39:49 +09:00
karajan1001 b37524603a Queue remove: Can remove successed,failed tasks msgs
Followed from #7592

1. Seperate remove method to a new file.
2. Add queued, failed, processed flags to remove.
3. Add new unit tests for queue status --queue/fail/success
4. Implement methods to remove done tasks.
5. add some new unit and functional test for celery_queue.remove
6. bump into dvc-task version 0.0.13
2022-07-12 21:39:49 +09:00
Peter Rowlands ff3b23c1b6 dvc-task: bump to 0.1.0
- ends requirement to install kombu + dvc-task from source
2022-07-12 21:39:49 +09:00
Peter Rowlands 4e005411fe exp show: use unique executor name for dvc-task & standalone tempdir exps 2022-07-12 21:39:49 +09:00
Peter Rowlands 1f7b802f79 exp show: collect running standalone tempdir experiments 2022-07-12 21:39:49 +09:00
Peter Rowlands 7c2bd340db exp run: update --run-all logs behavior 2022-07-12 21:39:49 +09:00
Peter Rowlands 9e0788aa3a exp run: preserve --temp standalone behavior 2022-07-12 21:39:49 +09:00
Peter Rowlands e1cf62a583 exp queue: add separate standalone/tempdir implementation 2022-07-12 21:39:49 +09:00
Peter Rowlands ce4cee184e exp queue: separate queue implementations into their own modules 2022-07-12 21:39:49 +09:00
Peter Rowlands db52c75221 dvc-task: bump to 0.0.12 2022-07-12 21:39:49 +09:00
karajan1001andPeter Rowlands bd74172633 queue status: add some unit test to increase the coverage
Followed by #7903

Add a new unit test to increase the coverage of test

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2022-07-12 21:39:49 +09:00
karajan1001 e5b4946309 queue status: some reviewed changes.
1. No `start -j` will only start worker (1,2,3,...,j). Will not start
   j new worker.
2. Distinguish active worker and idle worker.
2022-07-12 21:39:49 +09:00
karajan1001 3918db945a queue status: will show the current worker status
fix: #7950
Currently queue worker status are not show in `queue status` makes it
hard to tell if the worker is still living.

1. add a new method `active_worker` to return the currently running
   worker node name.
2. give every worker a unique name.
3. `queue status` will show how much worker running at present.
2022-07-12 21:39:49 +09:00
karajan1001 25b5d1d522 queue start: give every worker a unique name
Currently, all of our celery workers are called `dvc-exp-worker` and
they will write to the same log path.

We give every single worker a unique name and seperate they logs if
under a debug log level
2022-07-12 21:39:49 +09:00
dberenbaum 164efe089f queue: ui cleanup 2022-07-12 21:39:49 +09:00
karajan1001 4e82eb4978 queue remove: add remove support for short revs(#7840)
fix: #7840

Now queue remove can not accept short rev as name.

1. Refactor the queue entry match in `queue remove`
2. Better output if no queue task found for name.
3. short rev on kill can only be work for rev with lens longer than 4
2022-07-12 21:39:49 +09:00
karajan1001 16bb31a284 queue worker shutdown: disable restore
fix: #7884
The complete task restored back to the queue causing a task duplication.
Might related to a bug from the celery celery/celery#5663

Follow what in https://github.com/celery/celery/issues/5663#issuecomment-734871162
Disabled the restore_at_shutdown
2022-07-12 21:39:49 +09:00
karajan1001 d7a917e2ab queue status: use a short rev to represent tasks
1. use a short rev to represent tasks
2. Add a unit test for the result shown
2022-07-12 21:39:49 +09:00
Peter Rowlands 8dfcb0a36d exp run: update Ctrl+C handling for --temp and --run-all 2022-07-12 21:39:49 +09:00
Peter Rowlands 67cbdf6de8 exp queue: remove dvc queue attach command 2022-07-12 21:39:49 +09:00
Peter Rowlands f12091002f exp queue: add dvc queue logs command 2022-07-12 21:39:49 +09:00
Peter Rowlands 570f4345a0 exp queue: implement queue.logs() support 2022-07-12 21:39:49 +09:00
Peter Rowlands 425d0701c9 exp queue: return completed exps in queue status 2022-07-12 21:39:49 +09:00
Peter Rowlands 99a11f7056 exp: save failed queue runs in separate stash/reflog 2022-07-12 21:39:49 +09:00
Peter Rowlands 8ab46c1549 exp: simplify shutdown behavior 2022-07-12 21:39:49 +09:00
Peter Rowlands 8b663f2719 exp: use single atomic task instead of chaining 2022-07-12 21:39:49 +09:00
Peter Rowlands c0db513cf0 dvc-task: bump to 0.0.11 2022-07-12 21:39:49 +09:00
Peter Rowlands bf1bc9625d exp: use celery async wait in queue get_result 2022-07-12 21:39:49 +09:00
Peter Rowlands 686c15dab9 exp: use a single chain for all exp tasks
- removes separated scheduling for setup_exp
2022-07-12 21:39:49 +09:00
Peter Rowlands 10aa833f80 dvc-task: bump to 0.0.10 2022-07-12 21:39:49 +09:00
Peter Rowlands 9f5af04963 exp: update tests for match_queue_entry refactor 2022-07-12 21:39:49 +09:00
Peter Rowlands ac5d72967a dvc-task: bump to 0.0.9 2022-07-12 21:39:49 +09:00
karajan1001 bcc2f3b389 Reorganized get_queue_entry_by_names 2022-07-12 21:39:49 +09:00
karajan1001 3d0ee0d202 exp queue: monitor queue status (#7590)
fix: #7590
1. Add two new command `queue status` and `queue attach`
2. Add unit test for the CLI
3. Make `Running` exps can be found by `get_queue_entry_by_names`
2022-07-12 21:39:49 +09:00
karajan1001 6b8c87b58a Add more unit test and implement a new method
1. Add some more unit test for `celery_queue.kill`
2. Implement `kill` argument for `celery_queue.shutdown`
3. Use dataclass's internal methods instead of `__eq__`
2022-07-12 21:39:49 +09:00
Peter Rowlands fee1d02791 dvc-task: update to 0.0.8 2022-07-12 21:39:49 +09:00
Peter Rowlands d766c01d2d exp: enable late_ack to disable celery prefetch 2022-07-12 21:39:49 +09:00
Peter Rowlands dc56c4c9b0 exp: use full task chain when checking active tasks 2022-07-12 21:39:49 +09:00
Peter Rowlands 7ece26d58a tests: re-enable exp show celery tests 2022-07-12 21:39:49 +09:00
Peter Rowlands f26664a569 exp: re-enable run --run-all with celery queue 2022-07-12 21:39:49 +09:00
Peter Rowlands 4b0c65093a tests: use pytest-celery for exp tests 2022-07-12 21:39:49 +09:00
karajan1001 12fc8c4e7d exp queue: Add two command queue start and stop (#7589)
fix: #7589

1. Add two sub-command `dvc queue start` and `dvc queue stop`
2. Add a unit test to test them
2022-07-12 21:39:49 +09:00
karajan1001andPeter Rowlands a6cb4eb72e exp queue: remove queued experiments(#7591)
fix: #7591
1. Add a new command `dvc queue`
2. Add two sub-command `dvc queue remove` and `dvc queue kill`
3. Add a unit test to test them

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2022-07-12 21:39:49 +09:00
karajan1001 4df5a1321d exp: implement internal queue.kill support(#7587)
fix: #7587
1. Add implement kill method for local queue class
2. Add a test to make sure the following job will be success after the
   original job was killed.
3. Some refactoring work on `exp remove`
2022-07-12 21:39:49 +09:00
Peter Rowlands 22deca3a6a exp: use celery threadpool worker instead of prefork 2022-07-12 21:39:49 +09:00
Peter Rowlands dc4292c602 exp: implement queue shutdown 2022-07-12 21:39:49 +09:00
Peter Rowlands 56f90eb3c9 exp: add test_queue unit test helper for celery queue 2022-07-12 21:39:49 +09:00
Peter Rowlands 1c474e8f30 exp: re-enable most tmpdir/queue tests 2022-07-12 21:39:49 +09:00
Peter Rowlands 24654fe16c exp: use dvc.daemon to run worker on windows 2022-07-12 21:39:49 +09:00
Peter Rowlands 44820e74a3 exp: wait for task collection when getting exp result 2022-07-12 21:39:49 +09:00
Peter Rowlands c16908e9fc exp: use queue remove in gc 2022-07-12 21:39:49 +09:00
Peter Rowlands 33dbdd20af exp: exclude celery stash ref from util iterators 2022-07-12 21:39:49 +09:00
Peter Rowlands 69d7bdd0c0 exp: generate unique worker node names per DVC repo 2022-07-12 21:39:49 +09:00
Peter Rowlands b9f4398826 exp: start migrating info logging to ui 2022-07-12 21:39:49 +09:00
Peter Rowlands a6e7d43668 exp: return exp results for celery runs 2022-07-12 21:39:49 +09:00
Peter Rowlands e2ee69ddb2 dvc-task: update to 0.0.6 2022-07-12 21:39:49 +09:00
Peter Rowlands e8448345f1 exp: follow output on run-all 2022-07-12 21:39:49 +09:00
Peter Rowlands acab008c65 dvc-task: update to 0.0.3 2022-07-12 21:39:49 +09:00
Peter Rowlands 8c74775a4e exp: autostart celery worker 2022-07-12 21:39:49 +09:00
Peter Rowlands 9b31a4b463 exp: support remove from celery queue 2022-07-12 21:39:49 +09:00
Peter Rowlands 9f022c576c dvc-task: update to 0.0.2 2022-07-12 21:39:49 +09:00
Peter Rowlands 316029a9d2 exp: use queue status in exp show 2022-07-12 21:39:49 +09:00
Peter Rowlands 34fe643cee exp: add queue.iter_active for iterating over active exps 2022-07-12 21:39:49 +09:00
Peter Rowlands 6a01cc530e exp: use queue.iter_queued to get queued status in exp show 2022-07-12 21:39:49 +09:00
Peter Rowlands 81475bee09 dvc-task: update to 0.0.1 2022-07-12 21:39:49 +09:00
Peter Rowlands a167dff019 exp: implement celery exp task collection 2022-07-12 21:39:49 +09:00
Peter Rowlands cffd587952 exp: add queue-worker plumbing command 2022-07-12 21:39:49 +09:00
Peter Rowlands a0ae9d3f45 exp: migrate workspace repro to queues 2022-07-12 21:39:49 +09:00
Peter Rowlands e74f8f9e11 exp: add naive workspace queue 2022-07-12 21:39:49 +09:00
Peter Rowlands e45ca1e830 exp: start moving queueing/execution from __init__ 2022-07-12 21:39:49 +09:00
Peter Rowlands 46c734eb7e exp: temporarily mark tmpdir/queue tests as xfail 2022-07-12 21:39:49 +09:00
Peter Rowlands c17f7f03bd exp: initial celery app/task setup 2022-07-12 21:39:49 +09:00
Peter Rowlands 7113568c60 exp: refactor base.py into refs.py and exceptions.py 2022-07-12 21:39:49 +09:00
Peter Rowlands 6143146974 exp: refactor stash handling into separate module 2022-07-12 21:39:49 +09:00
15ada9a1c3 Implement --date to exp gc
1. Implement `--time` to `exp gc`
2. add a unit test for it.
3. Rename `--time` to `--date` and change the warning msg.
4. To get a better msg for `gc` and `exp gc`

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
Co-authored-by: Dave Berenbaum <[email protected]>
2022-07-12 13:53:46 +02:00
karajan1001andPeter Rowlands 217eb13495 Add argument commit_time to gc/brancher and iter_revs
1. Add argument `commit_time` to `gc`/`brancher` and `iter_revs`
2. Add unit test for iter_revs.

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2022-07-12 13:53:46 +02:00
karajan1001andDave Berenbaum 20d1f7a87d gc: add a new flag --time (#678)
fix #678
1. Add a new flag `--time` to `dvc gc`.
2. Modify the unit test of `dvc gc`
3. Fix an argument checking error and update the error msg.

Co-authored-by: Dave Berenbaum <[email protected]>
2022-07-12 13:53:46 +02:00
karajan1001 9c59689aa7 Gc: Move old unittest gc tests to pytest 2022-07-12 13:53:46 +02:00
David de la Iglesia Castro d47e3c40b8 tests: limit printed durations to 50 2022-07-11 12:36:37 +03:00
180 changed files with 6506 additions and 2254 deletions
+50 -110
View File
@@ -6,152 +6,92 @@ on:
release:
types: [released, prereleased]
workflow_dispatch:
jobs:
deb:
runs-on: ubuntu-18.04
binary:
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: "exe", os: "windows-2019", asset: "dvc-${{ github.event.release.tag_name }}.exe"}
name: ${{ matrix.pkg }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Set up Ruby 2.4
uses: actions/[email protected]
cache: pip
cache-dependency-path: |
setup.cfg
scripts/build-requirements.txt
- name: Set up Ruby 2.6
uses: ruby/setup-ruby@v1
if: matrix.pkg != 'exe'
with:
ruby-version: 2.4
- name: Install deps
run: |
pip install .[all]
pip install -r scripts/build-requirements.txt
gem install --no-document fpm
- name: Build deb
run: python scripts/build.py deb
- name: Publish deb
if: github.event_name == 'release'
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: scripts/fpm/dvc_${{ github.event.release.tag_name }}_amd64.deb
asset_name: dvc_${{ github.event.release.tag_name }}_amd64.deb
asset_content_type: binary/octet-stream
rpm:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Set up Ruby 2.4
uses: actions/[email protected]
with:
ruby-version: 2.4
- name: Install deps
run: |
pip install .[all]
pip install -r scripts/build-requirements.txt
gem install --no-document fpm
- name: Build rpm
run: python scripts/build.py rpm
- name: Publish rpm
if: github.event_name == 'release'
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: scripts/fpm/dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm
asset_name: dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm
asset_content_type: binary/octet-stream
osxpkg:
runs-on: macos-10.15
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Set up Ruby 2.4
uses: actions/[email protected]
with:
ruby-version: 2.4
- name: Install deps
run: |
# available lxml (webdav dependency) wheels are built with an old
# SDK, which breaks osxpkg notarization. Building from scratch
# instead.
pip install .[all] --no-binary lxml
pip install -r scripts/build-requirements.txt
gem install --no-document fpm
- name: Build
run: python scripts/build.py osxpkg
- name: Publish
if: github.event_name == 'release'
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: scripts/fpm/dvc-${{ github.event.release.tag_name }}.pkg
asset_name: dvc-${{ github.event.release.tag_name }}.pkg
asset_content_type: binary/octet-stream
exe:
runs-on: windows-2019
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
ruby-version: '2.6'
- name: Install fpm
if: matrix.pkg != 'exe'
run: gem install --no-document fpm
- name: Install deps
run: |
pip install -U wheel pip setuptools
pip install .[all]
pip install -r scripts/build-requirements.txt
- name: Pull images
run: |
dvc pull
- name: Build
run: python scripts\build.py exe
shell: cmd
- name: Publish
run: dvc pull
- name: Build ${{ matrix.pkg }}
run: python scripts/build.py ${{ matrix.pkg }}
- name: Publish ${{ matrix.pkg }}
if: github.event_name == 'release'
uses: actions/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/[email protected]
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: scripts/innosetup/dvc-${{ github.event.release.tag_name }}.exe
asset_name: dvc-${{ github.event.release.tag_name }}.exe
asset_path: scripts/${{ matrix.pkg == 'exe' && 'innosetup' || 'fpm' }}/${{ matrix.asset }}
asset_name: ${{ matrix.asset }}
asset_content_type: binary/octet-stream
pip:
runs-on: ubuntu-18.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python 3.8
uses: actions/setup-python@v4
with:
python-version: 3.8
cache: pip
cache-dependency-path: setup.cfg
- name: Install
run: pip install ".[all,tests]"
run: |
pip install -U wheel pip build setuptools
pip install ".[all,tests]"
- name: Build packages
run: |
pip install -U pip
./scripts/build_package.sh
- name: Publish packages to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@master
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_TOKEN }}
+99 -122
View File
@@ -2,91 +2,100 @@
`Website <https://dvc.org>`_
• `Docs <https://dvc.org/doc>`_
• `VS Code Extension <https://marketplace.visualstudio.com/items?itemName=Iterative.dvc>`_
• `Blog <http://blog.dataversioncontrol.com>`_
• `Twitter <https://twitter.com/DVCorg>`_
• `Chat (Community & Support) <https://dvc.org/chat>`_
• `Tutorial <https://dvc.org/doc/get-started>`_
• `Mailing List <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_
• `Related Technologies <https://dvc.org/doc/user-guide/related-technologies>`_
• `How DVC works`_
• `VS Code Extension`_
• `Installation`_
• `Contributing`_
• `Community and Support`_
|CI| |Maintainability| |Coverage| |VS Code| |DOI|
|CI| |Python Version| |Coverage| |VS Code| |DOI|
|PyPI| |Packages| |Brew| |Conda| |Choco| |Snap|
|
**Data Version Control** or **DVC** is a command line tool and `VS Code Extension <https://marketplace.visualstudio.com/items?itemName=Iterative.dvc>`_ to help you develop reproducible machine learning projects:
**Data Version Control** or **DVC** is a command line tool and `VS Code Extension`_ to help you develop reproducible machine learning projects:
#. **Version** your data and models. Store them in your cloud storage but keep
their version info in your Git repo.
#. **Version** your data and models.
Store them in your cloud storage but keep their version info in your Git repo.
#. **Iterate** fast with lightweight pipelines. When you make changes, only run
the steps impacted by those changes.
#. **Iterate** fast with lightweight pipelines.
When you make changes, only run the steps impacted by those changes.
#. **Track** experiments in your local Git repo (no servers needed).
#. **Compare** any data, code, parameters, model, or performance plots
#. **Compare** any data, code, parameters, model, or performance plots.
#. **Share** experiments and automatically reproduce anyone's experiment.
.. contents:: **Contents**
:backlinks: none
How DVC works
=============
We encourage you to read our `Get Started <https://dvc.org/doc/get-started>`_ guide to better understand what DVC
is and how it can fit your scenarios.
The easiest (but not perfect!) *analogy* to describe it: DVC is Git (or Git-LFS to be precise) & Makefiles
made right and tailored specifically for ML and Data Science scenarios.
#. ``Git/Git-LFS`` part - DVC helps store and share data artifacts and models, connecting them with a Git repository.
#. ``Makefile``\ s part - DVC describes how one data or model artifact was built from other data and code.
DVC usually runs along with Git. Git is used as usual to store and version code (including DVC meta-files). DVC helps
to store data and model files seamlessly out of Git, while preserving almost the same user experience as if they
were stored in Git itself. To store and share the data cache, DVC supports multiple remotes - any cloud (S3, Azure,
Google Cloud, etc) or any on-premise network storage (via SSH, for example).
|Flowchart|
The DVC pipelines (computational graph) feature connects code and data together. It is possible to explicitly
specify all steps required to produce a model: input dependencies including data, commands to run,
and output information to be saved. See the quick start sections below or
the `Get Started <https://dvc.org/doc/get-started>`_ tutorial to learn more.
Quick start
===========
Please read `Get Started <https://dvc.org/doc/get-started>`_ guide for a full version. Common workflow commands include:
Please read our `Command Reference <https://dvc.org/doc/command-reference>`_ for a complete list.
+-----------------------------------+----------------------------------------------------------------------------+
| Step | Command |
+===================================+============================================================================+
| Track data | | ``$ git add train.py`` |
| | | ``$ dvc add images.zip`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Connect code and data by commands | | ``$ dvc run -n prepare -d images.zip -o images/ unzip -q images.zip`` |
| | | ``$ dvc run -n train -d images/ -d train.py -o model.p python train.py`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Make changes and reproduce | | ``$ vi train.py`` |
| | | ``$ dvc repro model.p.dvc`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Share code | | ``$ git add .`` |
| | | ``$ git commit -m 'The baseline model'`` |
| | | ``$ git push`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Share data and ML models | | ``$ dvc remote add myremote -d s3://mybucket/image_cnn`` |
| | | ``$ dvc push`` |
+-----------------------------------+----------------------------------------------------------------------------+
A common CLI workflow includes:
+-----------------------------------+----------------------------------------------------------------------------------------------------+
| Task | Terminal |
+===================================+====================================================================================================+
| Track data | | ``$ git add train.py params.yaml`` |
| | | ``$ dvc add images/`` |
+-----------------------------------+----------------------------------------------------------------------------------------------------+
| Connect code and data | | ``$ dvc stage add -n featurize -d images/ -o features/ python featurize.py`` |
| | | ``$ dvc stage add -n train -d features/ -d train.py -o model.p -M metrics.json python train.py`` |
+-----------------------------------+----------------------------------------------------------------------------------------------------+
| Make changes and experiment | | ``$ dvc exp run -n exp-baseline`` |
| | | ``$ vi train.py`` |
| | | ``$ dvc exp run -n exp-code-change`` |
+-----------------------------------+----------------------------------------------------------------------------------------------------+
| Compare and select experiments | | ``$ dvc exp show`` |
| | | ``$ dvc exp apply exp-baseline`` |
+-----------------------------------+----------------------------------------------------------------------------------------------------+
| Share code | | ``$ git add .`` |
| | | ``$ git commit -m 'The baseline model'`` |
| | | ``$ git push`` |
+-----------------------------------+----------------------------------------------------------------------------------------------------+
| Share data and ML models | | ``$ dvc remote add myremote -d s3://mybucket/image_cnn`` |
| | | ``$ dvc push`` |
+-----------------------------------+----------------------------------------------------------------------------------------------------+
How DVC works
=============
We encourage you to read our `Get Started
<https://dvc.org/doc/get-started>`_ docs to better understand what DVC
does and how it can fit your scenarios.
The closest *analogies* to describe the main DVC features are these:
#. **Git for data**: Store and share data artifacts (like Git-LFS but without a server) and models, connecting them with a Git repository. Data management meets GitOps!
#. **Makefiles** for ML: Describes how data or model artifacts are built from other data and code in a standard format. Now you can version your data pipelines with Git.
#. Local **experiment tracking**: Turn your machine into an ML experiment management platform, and collaborate with others using existing Git hosting (Github, Gitlab, etc.).
Git is employed as usual to store and version code (including DVC meta-files as placeholders for data).
DVC `stores data and model files <https://dvc.org/doc/start/data-management>`_ seamlessly in a cache outside of Git, while preserving almost the same user experience as if they were in the repo.
To share and back up the *data cache*, DVC supports multiple remote storage platforms - any cloud (S3, Azure, Google Cloud, etc.) or on-premise network storage (via SSH, for example).
|Flowchart|
`DVC pipelines <https://dvc.org/doc/start/data-management/pipelines>`_ (computational graphs) connect code and data together.
They specify all steps required to produce a model: input dependencies including code, data, commands to run; and output information to be saved.
Last but not least, `DVC Experiment Versioning <https://dvc.org/doc/start/experiments>`_ lets you prepare and run a large number of experiments.
Their results can be filtered and compared based on hyperparameters and metrics, and visualized with multiple plots.
.. _`VS Code Extension`:
Visual Studio Code Extension
============================
|VS Code|
To get use DVC as a GUI right from your VS Code IDE, install the `DVC Extension <https://marketplace.visualstudio.com/items?itemName=Iterative.dvc>`_ from the Marketplace.
To use DVC as a GUI right from your VS Code IDE, install the `DVC Extension <https://marketplace.visualstudio.com/items?itemName=Iterative.dvc>`_ from the Marketplace.
It currently features experiment tracking and data management, and more features (data pipeline support, etc.) are coming soon!
|VS Code Extension Overview|
@@ -110,11 +119,10 @@ Snapcraft (Linux)
snap install dvc --classic
This corresponds to the latest tagged release.
Add ``--beta`` for the latest tagged release candidate,
or ``--edge`` for the latest ``main`` version.
Add ``--beta`` for the latest tagged release candidate, or ``--edge`` for the latest ``main`` version.
Choco (Chocolatey/Windows)
--------------------------
Chocolatey (Windows)
--------------------
|Choco|
@@ -122,8 +130,8 @@ Choco (Chocolatey/Windows)
choco install dvc
Brew (Homebrew/Mac OS)
----------------------
Brew (mac OS)
-------------
|Brew|
@@ -131,8 +139,8 @@ Brew (Homebrew/Mac OS)
brew install dvc
Conda (Anaconda)
----------------
Anaconda (Any platform)
-----------------------
|Conda|
@@ -141,11 +149,10 @@ Conda (Anaconda)
conda install -c conda-forge mamba # installs much faster than conda
mamba install -c conda-forge dvc
Depending on the remote storage type you plan to use to keep and share your data, you might need to
install optional dependencies: `dvc-s3`, `dvc-azure`, `dvc-gdrive`, `dvc-gs`, `dvc-oss`, `dvc-ssh`.
Depending on the remote storage type you plan to use to keep and share your data, you might need to install optional dependencies: `dvc-s3`, `dvc-azure`, `dvc-gdrive`, `dvc-gs`, `dvc-oss`, `dvc-ssh`.
pip (PyPI)
----------
PyPI (Python)
-------------
|PyPI|
@@ -153,10 +160,8 @@ pip (PyPI)
pip install dvc
Depending on the remote storage type you plan to use to keep and share your data, you might need to specify
one of the optional dependencies: ``s3``, ``gs``, ``azure``, ``oss``, ``ssh``. Or ``all`` to include them all.
The command should look like this: ``pip install dvc[s3]`` (in this case AWS S3 dependencies such as ``boto3``
will be installed automatically).
Depending on the remote storage type you plan to use to keep and share your data, you might need to specify one of the optional dependencies: ``s3``, ``gs``, ``azure``, ``oss``, ``ssh``. Or ``all`` to include them all.
The command should look like this: ``pip install 'dvc[s3]'`` (in this case AWS S3 dependencies such as ``boto3`` will be installed automatically).
To install the development version, run:
@@ -164,13 +169,13 @@ To install the development version, run:
pip install git+git://github.com/iterative/dvc
Package
-------
Package (Platform-specific)
---------------------------
|Packages|
Self-contained packages for Linux, Windows, and Mac are available. The latest version of the packages
can be found on the GitHub `releases page <https://github.com/iterative/dvc/releases>`_.
Self-contained packages for Linux, Windows, and Mac are available.
The latest version of the packages can be found on the GitHub `releases page <https://github.com/iterative/dvc/releases>`_.
Ubuntu / Debian (deb)
^^^^^^^^^^^^^^^^^^^^^
@@ -190,64 +195,32 @@ Fedora / CentOS (rpm)
sudo yum update
sudo yum install dvc
Comparison to related technologies
==================================
#. Data Engineering tools such as `AirFlow <https://airflow.apache.org/>`_,
`Luigi <https://github.com/spotify/luigi>`_, and others - in DVC data,
model and ML pipelines represent a single ML project focused on data
scientists' experience. Data engineering tools orchestrate multiple data
projects and focus on efficient execution. A DVC project can be used from
existing data pipelines as a single execution step.
#. `Git-annex <https://git-annex.branchable.com/>`_ - DVC uses the idea of storing the content of large files (which should
not be in a Git repository) in a local key-value store, and uses file hardlinks/symlinks instead of
copying/duplicating files.
#. `Git-LFS <https://git-lfs.github.com/>`_ - DVC is compatible with many
remote storage services (S3, Google Cloud, Azure, SSH, etc). DVC also
uses reflinks or hardlinks to avoid copy operations on checkouts; thus
handling large data files much more efficiently.
#. Makefile (and analogues including ad-hoc scripts) - DVC tracks
dependencies (in a directed acyclic graph).
#. `Workflow Management Systems <https://en.wikipedia.org/wiki/Workflow_management_system>`_ - DVC is a workflow
management system designed specifically to manage machine learning experiments. DVC is built on top of Git.
#. `DAGsHub <https://dagshub.com/>`_ - online service to host DVC
projects. It provides a useful UI around DVC repositories and integrates
other tools.
#. `DVC Studio <https://studio.iterative.ai/>`_ - official online
platform for DVC projects. It can be used to manage data and models, run
and track experiments, and visualize and share results. Also, it
integrates with `CML (CI/CD for ML) <https://cml.dev/>`__ for training
models in the cloud or Kubernetes.
Contributing
============
|Maintainability|
Contributions are welcome! Please see our `Contributing Guide <https://dvc.org/doc/user-guide/contributing/core>`_ for more
details. Thanks to all our contributors!
Contributions are welcome!
Please see our `Contributing Guide <https://dvc.org/doc/user-guide/contributing/core>`_ for more details.
Thanks to all our contributors!
|Contribs|
Mailing List
============
Community and Support
=====================
Want to stay up to date? Want to help improve DVC by participating in our occasional polls? Subscribe to our `mailing list <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_. No spam, really low traffic.
* `Twitter <https://twitter.com/DVCorg>`_
* `Forum <https://discuss.dvc.org/>`_
* `Discord Chat <https://dvc.org/chat>`_
* `Email <mailto:[email protected]>`_
* `Mailing List <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_
Copyright
=========
This project is distributed under the Apache license version 2.0 (see the LICENSE file in the project root).
By submitting a pull request to this project, you agree to license your contribution under the Apache license version
2.0 to this project.
By submitting a pull request to this project, you agree to license your contribution under the Apache license version 2.0 to this project.
Citation
========
@@ -275,6 +248,10 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
:target: https://codeclimate.com/github/iterative/dvc
:alt: Code Climate
.. |Python Version| image:: https://img.shields.io/pypi/pyversions/dvc
:target: https://pypi.org/project/dvc
:alt: Python Version
.. |Coverage| image:: https://codecov.io/gh/iterative/dvc/branch/main/graph/badge.svg
:target: https://codecov.io/gh/iterative/dvc
:alt: Codecov
+9
View File
@@ -189,6 +189,15 @@ def main(argv=None): # noqa: C901
except KeyboardInterrupt:
logger.exception("interrupted by the user")
ret = 252
except BrokenPipeError:
import os
# Python flushes standard streams on exit; redirect remaining output
# to devnull to avoid another BrokenPipeError at shutdown
# See: https://docs.python.org/3/library/signal.html#note-on-sigpipe
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, sys.stdout.fileno())
ret = 141 # 128 + 13 (SIGPIPE)
except NotDvcRepoError:
logger.exception("")
ret = 253
+1 -1
View File
@@ -13,7 +13,7 @@ class CmdBase(ABC):
os.chdir(args.cd)
self.repo = Repo(uninitialized=self.UNINITIALIZED)
self.repo: "Repo" = Repo(uninitialized=self.UNINITIALIZED)
self.config = self.repo.config
self.args = args
+4
View File
@@ -14,6 +14,7 @@ from dvc.commands import (
config,
daemon,
dag,
data,
data_sync,
destroy,
diff,
@@ -33,6 +34,7 @@ from dvc.commands import (
move,
params,
plots,
queue,
remote,
remove,
repro,
@@ -50,6 +52,7 @@ logger = logging.getLogger(__name__)
COMMANDS = [
init,
queue,
get,
get_url,
destroy,
@@ -86,6 +89,7 @@ COMMANDS = [
experiments,
check_ignore,
machine,
data,
]
+181
View File
@@ -0,0 +1,181 @@
import argparse
import logging
from typing import TYPE_CHECKING
from funcy import chunks, compact, log_durations
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link, fix_subparsers
from dvc.ui import ui
from dvc.utils import colorize
if TYPE_CHECKING:
from dvc.repo.data import Status as DataStatus
logger = logging.getLogger(__name__)
class CmdDataStatus(CmdBase):
COLORS = {
"not_in_cache": "red",
"committed": "green",
"uncommitted": "yellow",
"untracked": "cyan",
}
LABELS = {
"not_in_cache": "Not in cache",
"committed": "DVC committed changes",
"uncommitted": "DVC uncommitted changes",
"untracked": "Untracked files",
"unchanged": "DVC unchanged files",
}
HINTS = {
"not_in_cache": 'use "dvc pull <file>..." to download files',
"committed": "git commit the corresponding dvc files "
"to update the repo",
"uncommitted": 'use "dvc commit <file>..." to track changes',
"untracked": 'use "git add <file> ..." or '
'dvc add <file>..." to commit to git or to dvc',
"git_dirty": "there are {}changes not tracked by dvc, "
'use "git status" to see',
}
@staticmethod
def _process_status(status: "DataStatus"):
"""Flatten stage status, and filter empty stage status contents."""
for stage, stage_status in status.items():
items = stage_status
if isinstance(stage_status, dict):
items = {
file: state
for state, files in stage_status.items()
for file in files
if state != "unknown"
}
if not items:
continue
yield stage, items
@classmethod
def _show_status(cls, status: "DataStatus") -> int:
git_info = status.pop("git") # type: ignore[misc]
result = dict(cls._process_status(status))
if not result:
no_changes = "No changes"
if git_info.get("is_empty", False):
no_changes += " in an empty git repo"
ui.write(f"{no_changes}.")
for idx, (stage, stage_status) in enumerate(result.items()):
if idx:
ui.write()
label = cls.LABELS.get(stage, stage.capitalize() + " files")
header = f"{label}:"
color = cls.COLORS.get(stage, None)
ui.write(header)
if hint := cls.HINTS.get(stage):
ui.write(f" ({hint})")
if isinstance(stage_status, dict):
items = [
": ".join([state, file])
for file, state in stage_status.items()
]
else:
items = stage_status
tabs = "\t".expandtabs(8)
for chunk in chunks(1000, items):
out = "\n".join(tabs + item for item in chunk)
ui.write(colorize(out, color))
if (hint := cls.HINTS.get("git_dirty")) and git_info.get("is_dirty"):
message = hint.format("other " if result else "")
ui.write(f"[blue]({message})[/]", styled=True)
return 0
def run(self) -> int:
with log_durations(logger.trace, "in data_status"): # type: ignore
status = self.repo.data_status(
granular=self.args.granular,
untracked_files=self.args.untracked_files,
with_dirs=self.args.with_dirs,
)
if not self.args.unchanged:
status.pop("unchanged") # type: ignore[misc]
if self.args.untracked_files == "no":
status.pop("untracked")
if self.args.json:
status.pop("git") # type: ignore[misc]
ui.write_json(compact(status))
return 0
return self._show_status(status)
def add_parser(subparsers, parent_parser):
data_parser = subparsers.add_parser(
"data",
parents=[parent_parser],
formatter_class=argparse.RawDescriptionHelpFormatter,
)
data_subparsers = data_parser.add_subparsers(
dest="cmd",
help="Use `dvc data CMD --help` to display command-specific help.",
)
fix_subparsers(data_subparsers)
DATA_STATUS_HELP = (
"Show changes between the last git commit, "
"the dvcfiles and the workspace."
)
data_status_parser = data_subparsers.add_parser(
"status",
parents=[parent_parser],
description=append_doc_link(DATA_STATUS_HELP, "data/status"),
formatter_class=argparse.RawDescriptionHelpFormatter,
help=DATA_STATUS_HELP,
)
data_status_parser.add_argument(
"--json",
action="store_true",
default=False,
help="Show output in JSON format.",
)
data_status_parser.add_argument(
"--show-json",
action="store_true",
default=False,
dest="json",
help=argparse.SUPPRESS,
)
data_status_parser.add_argument(
"--granular",
action="store_true",
default=False,
help="Show granular file-level info for DVC-tracked directories.",
)
data_status_parser.add_argument(
"--unchanged",
action="store_true",
default=False,
help="Show unmodified DVC-tracked files.",
)
data_status_parser.add_argument(
"--untracked-files",
choices=["no", "all"],
default="no",
const="all",
nargs="?",
help="Show untracked files.",
)
data_status_parser.add_argument(
"--with-dirs",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
data_status_parser.set_defaults(func=CmdDataStatus)
+2 -1
View File
@@ -43,7 +43,8 @@ class CmdDataPull(CmdDataBase):
)
self.log_summary(stats)
except (CheckoutError, DvcException) as exc:
self.log_summary(getattr(exc, "stats", {}))
if stats := getattr(exc, "stats", {}):
self.log_summary(stats)
logger.exception("failed to pull data from the cloud")
return 1
+2
View File
@@ -15,6 +15,7 @@ from dvc.commands.experiments import (
ls,
pull,
push,
queue_worker,
remove,
run,
show,
@@ -30,6 +31,7 @@ SUB_COMMANDS = [
ls,
pull,
push,
queue_worker,
remove,
run,
show,
+1 -1
View File
@@ -29,6 +29,6 @@ def add_parser(experiments_subparsers, parent_parser):
"experiment", help="Experiment to be promoted."
)
experiments_branch_parser.add_argument(
"branch", help="Git branch name to use."
"branch", help="Name for the new Git branch"
)
experiments_branch_parser.set_defaults(func=CmdExperimentsBranch)
+29 -8
View File
@@ -12,8 +12,8 @@ logger = logging.getLogger(__name__)
def _raise_error_if_all_disabled(**kwargs):
if not any(kwargs.values()):
raise InvalidArgumentError(
"Either of `-w|--workspace`, `-a|--all-branches`, `-T|--all-tags` "
"or `--all-commits` needs to be set."
"Either of `-w|--workspace`, `-a|--all-branches`, `-T|--all-tags`,"
" `--all-commits` or `--date` needs to be set."
)
@@ -23,6 +23,7 @@ class CmdExperimentsGC(CmdBase):
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
commit_date=self.args.commit_date,
workspace=self.args.workspace,
)
@@ -31,13 +32,21 @@ class CmdExperimentsGC(CmdBase):
msg += "the workspace"
if self.args.all_commits:
msg += " and all git commits"
elif self.args.all_branches and self.args.all_tags:
msg += " and all git branches and tags"
elif self.args.all_branches:
msg += " and all git branches"
elif self.args.all_tags:
msg += " and all git tags"
else:
if self.args.all_branches and self.args.all_tags:
msg += " and all git branches and tags"
elif self.args.all_branches:
msg += " and all git branches"
elif self.args.all_tags:
msg += " and all git tags"
if self.args.commit_date:
msg += (
" and all git commits before date "
f"{self.args.commit_date}"
)
msg += " of the current repo."
if self.args.queued:
msg += " Run queued experiments will be preserved."
else:
@@ -54,6 +63,7 @@ class CmdExperimentsGC(CmdBase):
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
workspace=self.args.workspace,
commit_date=self.args.commit_date,
queued=self.args.queued,
)
@@ -110,6 +120,17 @@ def add_parser(experiments_subparsers, parent_parser):
default=False,
help="Keep experiments derived from all Git commits.",
)
experiments_gc_parser.add_argument(
"--date",
type=str,
dest="commit_date",
metavar="<YYYY-MM-DD>",
default=None,
help=(
"Keep experiments from the commits after (inclusive) a certain "
"date. Date must match the extended ISO 8601 format (YYYY-MM-DD)."
),
)
experiments_gc_parser.add_argument(
"--queued",
action="store_true",
+25
View File
@@ -0,0 +1,25 @@
import logging
from dvc.cli.command import CmdBase
logger = logging.getLogger(__name__)
class CmdQueueWorker(CmdBase):
"""Run the exp queue worker."""
def run(self):
self.repo.experiments.celery_queue.worker.start(self.args.name)
return 0
def add_parser(experiments_subparsers, parent_parser):
QUEUE_WORKER_HELP = "Run the exp queue worker."
parser = experiments_subparsers.add_parser(
"queue-worker",
parents=[parent_parser],
description=QUEUE_WORKER_HELP,
add_help=False,
)
parser.add_argument("name", help="Celery worker name.")
parser.set_defaults(func=CmdQueueWorker)
+4 -5
View File
@@ -10,23 +10,22 @@ logger = logging.getLogger(__name__)
class CmdExperimentsRemove(CmdBase):
def raise_error_if_all_disabled(self):
def check_arguments(self):
if not any(
[
self.args.experiment,
self.args.all_commits,
self.args.rev,
self.args.queue,
]
):
) ^ bool(self.args.experiment):
raise InvalidArgumentError(
"Either provide an `experiment` argument, or use the "
"`--rev` or `--all-commits` flag."
"`--rev` or `--all-commits` or `--queue` flag."
)
def run(self):
self.raise_error_if_all_disabled()
self.check_arguments()
removed_list = self.repo.experiments.remove(
exp_names=self.args.experiment,
+2 -2
View File
@@ -138,7 +138,7 @@ def _collect_rows(
row_dict["Experiment"] = exp.get("name", "")
row_dict["rev"] = name_rev
row_dict["typ"] = typ
row_dict["Created"] = _format_time(
row_dict["Created"] = format_time(
exp.get("timestamp"), fill_value, iso
)
row_dict["parent"] = parent
@@ -218,7 +218,7 @@ def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
return ret
def _format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
def format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
if datetime_obj is None:
return fill_value
+62 -10
View File
@@ -11,29 +11,46 @@ logger = logging.getLogger(__name__)
class CmdGC(CmdBase):
def run(self):
from dvc.repo.gc import _raise_error_if_all_disabled
from dvc.repo.gc import _validate_args
_raise_error_if_all_disabled(
_validate_args(
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
all_experiments=self.args.all_experiments,
commit_date=self.args.commit_date,
workspace=self.args.workspace,
rev=self.args.rev,
num=self.args.num,
)
if self.args.rev:
self.args.num = self.args.num or 1
msg = "This will remove all cache except items used in "
msg += "the workspace"
if self.args.all_commits:
msg += " and all git commits"
elif self.args.all_branches and self.args.all_tags:
msg += " and all git branches and tags"
elif self.args.all_branches:
msg += " and all git branches"
elif self.args.all_tags:
msg += " and all git tags"
elif self.args.all_experiments:
msg += " and all experiments"
else:
if self.args.all_branches and self.args.all_tags:
msg += " and all git branches and tags"
elif self.args.all_branches:
msg += " and all git branches"
elif self.args.all_tags:
msg += " and all git tags"
if self.args.commit_date:
msg += (
" and all git commits before date "
f"{self.args.commit_date}"
)
if self.args.rev:
msg += (
f" and last {self.args.num} commits from {self.args.rev}"
)
if self.args.all_experiments:
msg += " and all experiments"
if self.args.repos:
msg += " of the current and the following repos:"
@@ -53,12 +70,15 @@ class CmdGC(CmdBase):
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
all_experiments=self.args.all_experiments,
commit_date=self.args.commit_date,
cloud=self.args.cloud,
remote=self.args.remote,
force=self.args.force,
jobs=self.args.jobs,
repos=self.args.repos,
workspace=self.args.workspace,
rev=self.args.rev,
num=self.args.num,
)
return 0
@@ -83,6 +103,26 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Keep data files used in the current workspace.",
)
gc_parser.add_argument(
"--rev",
type=str,
default=None,
help="Keep data files used in the specified <commit>.",
metavar="<commit>",
)
gc_parser.add_argument(
"-n",
"--num",
type=int,
dest="num",
metavar="<num>",
help=(
"Keep data files used in the last `num` commits "
"starting from the `--rev` <commit>. "
"Only used if `--rev` is also provided. "
"Defaults to `1`."
),
)
gc_parser.add_argument(
"-a",
"--all-branches",
@@ -104,6 +144,18 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Keep data files for all Git commits.",
)
gc_parser.add_argument(
"--date",
type=str,
dest="commit_date",
metavar="<YYYY-MM-DD>",
default=None,
help=(
"Keep cached data referenced in the commits after ( inclusive )"
" a certain time. Date must match the extended ISO 8601 format "
"(YYYY-MM-DD)."
),
)
gc_parser.add_argument(
"--all-experiments",
action="store_true",
+11 -2
View File
@@ -19,6 +19,7 @@ class CmdImport(CmdBase):
fname=self.args.file,
rev=self.args.rev,
no_exec=self.args.no_exec,
no_download=self.args.no_download,
desc=self.args.desc,
jobs=self.args.jobs,
)
@@ -69,11 +70,19 @@ def add_parser(subparsers, parent_parser):
help="Specify name of the .dvc file this command will generate.",
metavar="<filename>",
)
import_parser.add_argument(
no_download_exec_group = import_parser.add_mutually_exclusive_group()
no_download_exec_group.add_argument(
"--no-exec",
action="store_true",
default=False,
help="Only create .dvc file without actually downloading it.",
help="Only create .dvc file without actually importing target data.",
)
no_download_exec_group.add_argument(
"--no-download",
action="store_true",
default=False,
help="Create .dvc file including target data hash value(s)"
" but do not actually download the file(s).",
)
import_parser.add_argument(
"--desc",
+11 -2
View File
@@ -17,6 +17,7 @@ class CmdImportUrl(CmdBase):
out=self.args.out,
fname=self.args.file,
no_exec=self.args.no_exec,
no_download=self.args.no_download,
remote=self.args.remote,
to_remote=self.args.to_remote,
desc=self.args.desc,
@@ -66,11 +67,19 @@ def add_parser(subparsers, parent_parser):
help="Specify name of the .dvc file this command will generate.",
metavar="<filename>",
).complete = completion.DIR
import_parser.add_argument(
no_download_exec_group = import_parser.add_mutually_exclusive_group()
no_download_exec_group.add_argument(
"--no-exec",
action="store_true",
default=False,
help="Only create .dvc file without actually downloading it.",
help="Only create .dvc file without actually importing target data.",
)
no_download_exec_group.add_argument(
"--no-download",
action="store_true",
default=False,
help="Create .dvc file including target data hash value(s)"
" but do not actually download the file(s).",
)
import_parser.add_argument(
"--to-remote",
+20 -15
View File
@@ -155,11 +155,6 @@ class CmdPlots(CmdBase):
out=renderers_out,
templates_dir=self.repo.plots.templates_dir,
)
if self.args.show_vega:
renderer = first(filter(lambda r: r.TYPE == "vega", renderers))
if renderer:
ui.write_json(json.loads(renderer.get_filled_template()))
return 0
if self.args.json:
renderers = _filter_unhandled_renderers(renderers)
_show_json(renderers, self.args.split)
@@ -167,6 +162,12 @@ class CmdPlots(CmdBase):
_adjust_vega_renderers(renderers)
if self.args.show_vega:
renderer = first(filter(lambda r: r.TYPE == "vega", renderers))
if renderer:
ui.write_json(json.loads(renderer.get_filled_template()))
return 0
output_file: Path = (Path.cwd() / out).resolve() / "index.html"
if renderers:
@@ -229,6 +230,8 @@ class CmdPlotsTemplates(CmdBase):
"confusion_normalized",
"scatter",
"smooth",
"bar_horizontal_sorted",
"bar_horizontal",
]
def run(self):
@@ -270,8 +273,8 @@ def add_parser(subparsers, parent_parser):
fix_subparsers(plots_subparsers)
SHOW_HELP = (
"Generate plots from target files or plots definitions from "
"`dvc.yaml` file."
"Generate plots from target files or from `plots`"
" definitions in `dvc.yaml`."
)
plots_show_parser = plots_subparsers.add_parser(
"show",
@@ -283,9 +286,10 @@ def add_parser(subparsers, parent_parser):
plots_show_parser.add_argument(
"targets",
nargs="*",
help="Plots to visualize. Supports any file path, or plot name "
"defined in `dvc.yaml`. "
"Shows all plots by default.",
help=(
"Plots files or plot IDs from `dvc.yaml` to visualize. "
"Shows all plots by default."
),
).complete = completion.FILE
_add_props_arguments(plots_show_parser)
_add_output_argument(plots_show_parser)
@@ -293,8 +297,8 @@ def add_parser(subparsers, parent_parser):
plots_show_parser.set_defaults(func=CmdPlotsShow)
PLOTS_DIFF_HELP = (
"Show multiple versions of plot data "
"by plotting them in a single image."
"Show multiple versions of a plot by overlaying them "
"in a single image."
)
plots_diff_parser = plots_subparsers.add_parser(
"diff",
@@ -329,8 +333,8 @@ def add_parser(subparsers, parent_parser):
plots_diff_parser.set_defaults(func=CmdPlotsDiff)
PLOTS_MODIFY_HELP = (
"Modify display properties of data-series plot outputs "
"(has no effect on image-type plots)."
"Modify display properties of data-series plots "
"defined in stages (has no effect on image plots)."
)
plots_modify_parser = plots_subparsers.add_parser(
"modify",
@@ -340,7 +344,8 @@ def add_parser(subparsers, parent_parser):
formatter_class=argparse.RawDescriptionHelpFormatter,
)
plots_modify_parser.add_argument(
"target", help="Plot output to set properties to"
"target",
help="Plots file to set properties for (defined at the stage level).",
).complete = completion.FILE
_add_props_arguments(plots_modify_parser)
plots_modify_parser.add_argument(
+34
View File
@@ -0,0 +1,34 @@
import argparse
from dvc.cli.utils import append_doc_link, fix_subparsers
from dvc.commands.queue import kill, logs, remove, start, status, stop
SUB_COMMANDS = [
start,
stop,
status,
logs,
remove,
kill,
]
def add_parser(subparsers, parent_parser):
QUEUE_HELP = "Commands to manage experiments queue."
queue_parser = subparsers.add_parser(
"queue",
parents=[parent_parser],
description=append_doc_link(QUEUE_HELP, "queue"),
formatter_class=argparse.RawDescriptionHelpFormatter,
help=QUEUE_HELP,
)
queue_subparsers = queue_parser.add_subparsers(
dest="cmd",
help="Use `dvc queue CMD --help` to display " "command-specific help.",
)
fix_subparsers(queue_subparsers)
for cmd in SUB_COMMANDS:
cmd.add_parser(queue_subparsers, parent_parser)
+34
View File
@@ -0,0 +1,34 @@
import argparse
import logging
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
logger = logging.getLogger(__name__)
class CmdQueueKill(CmdBase):
"""Kill exp task in queue."""
def run(self):
self.repo.experiments.celery_queue.kill(revs=self.args.task)
return 0
def add_parser(queue_subparsers, parent_parser):
QUEUE_KILL_HELP = "Kill actively running experiment queue tasks."
queue_kill_parser = queue_subparsers.add_parser(
"kill",
parents=[parent_parser],
description=append_doc_link(QUEUE_KILL_HELP, "queue/kill"),
help=QUEUE_KILL_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
queue_kill_parser.add_argument(
"task",
nargs="*",
help="Tasks in queue to kill.",
metavar="<task>",
)
queue_kill_parser.set_defaults(func=CmdQueueKill)
+56
View File
@@ -0,0 +1,56 @@
import argparse
import logging
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
logger = logging.getLogger(__name__)
class CmdQueueLogs(CmdBase):
"""Show output logs for a queued experiment."""
def run(self):
self.repo.experiments.celery_queue.logs(
rev=self.args.task,
encoding=self.args.encoding,
follow=self.args.follow,
)
return 0
def add_parser(queue_subparsers, parent_parser):
QUEUE_LOGS_HELP = (
"Show output logs for running and completed experiment queue tasks."
)
queue_logs_parser = queue_subparsers.add_parser(
"logs",
parents=[parent_parser],
description=append_doc_link(QUEUE_LOGS_HELP, "queue/logs"),
help=QUEUE_LOGS_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
queue_logs_parser.add_argument(
"-e",
"--encoding",
help=(
"Text encoding for log output. Defaults to system locale encoding."
),
metavar="<encoding>",
)
queue_logs_parser.add_argument(
"-f",
"--follow",
help=(
"Attach to task and follow additional live output. Only "
"applicable if the task is still running."
),
action="store_true",
)
queue_logs_parser.add_argument(
"task",
help="Task to show.",
metavar="<task>",
)
queue_logs_parser.set_defaults(func=CmdQueueLogs)
+95
View File
@@ -0,0 +1,95 @@
import argparse
import logging
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.exceptions import InvalidArgumentError
from dvc.ui import ui
logger = logging.getLogger(__name__)
class CmdQueueRemove(CmdBase):
"""Remove exp in queue."""
def check_arguments(self):
clear_flag = any(
[
self.args.all,
self.args.queued,
self.args.failed,
self.args.success,
]
)
if not (clear_flag ^ bool(self.args.task)):
raise InvalidArgumentError(
"Either provide an `tasks` argument, or use the "
"`--all`, `--queued`, `--failed`, `--success` flag."
)
def run(self):
self.check_arguments()
if self.args.all:
self.args.queued = True
self.args.failed = True
self.args.success = True
if self.args.queued or self.args.failed or self.args.success:
removed_list = self.repo.experiments.celery_queue.clear(
success=self.args.success,
queued=self.args.queued,
failed=self.args.failed,
)
else:
removed_list = self.repo.experiments.celery_queue.remove(
revs=self.args.task,
)
if removed_list:
removed = ", ".join(removed_list)
ui.write(f"Removed tasks in queue: {removed}")
else:
ui.write(f"No tasks found named {self.args.task}")
return 0
def add_parser(queue_subparsers, parent_parser):
QUEUE_REMOVE_HELP = "Remove queued and completed tasks from the queue."
queue_remove_parser = queue_subparsers.add_parser(
"remove",
parents=[parent_parser],
description=append_doc_link(QUEUE_REMOVE_HELP, "queue/remove"),
help=QUEUE_REMOVE_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
queue_remove_parser.add_argument(
"--all",
action="store_true",
help="Remove all queued and completed tasks from the queue.",
)
queue_remove_parser.add_argument(
"--queued",
action="store_true",
help="Remove all queued tasks from the queue.",
)
queue_remove_parser.add_argument(
"--success",
action="store_true",
help="Remove all successful tasks from the queue.",
)
queue_remove_parser.add_argument(
"--failed",
action="store_true",
help="Remove all failed tasks from the queue.",
)
queue_remove_parser.add_argument(
"task",
nargs="*",
help="Tasks to remove.",
metavar="<task>",
)
queue_remove_parser.set_defaults(func=CmdQueueRemove)
+48
View File
@@ -0,0 +1,48 @@
import argparse
import logging
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.ui import ui
logger = logging.getLogger(__name__)
class CmdQueueStart(CmdBase):
"""Start exp queue workers."""
def run(self):
started = self.repo.experiments.celery_queue.start_workers(
self.args.jobs
)
suffix = "s" if started > 1 else ""
ui.write(
f"Started '{started}' new experiments task queue worker{suffix}."
)
return 0
def add_parser(queue_subparsers, parent_parser):
QUEUE_START_HELP = "Start the experiments task queue worker."
queue_start_parser = queue_subparsers.add_parser(
"start",
parents=[parent_parser],
description=append_doc_link(QUEUE_START_HELP, "queue/start"),
help=QUEUE_START_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
queue_start_parser.add_argument(
"-j",
"--jobs",
type=int,
default=1,
help=(
"Maximum number of concurrent queue workers to start. "
"Defaults to 1."
),
metavar="<number>",
)
queue_start_parser.set_defaults(func=CmdQueueStart)
+62
View File
@@ -0,0 +1,62 @@
import argparse
import logging
from typing import List, Mapping, Optional
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.compare import TabularData
from dvc.ui import ui
from ..experiments.show import format_time
logger = logging.getLogger(__name__)
class CmdQueueStatus(CmdBase):
"""Show queue task and worker status."""
def run(self):
result: List[
Mapping[str, Optional[str]]
] = self.repo.experiments.celery_queue.status()
if result:
all_headers = ["Task", "Name", "Created", "Status"]
td = TabularData(all_headers)
for exp in result:
created = format_time(exp.get("timestamp"))
td.append(
[
exp["rev"][:7],
exp.get("name", ""),
created,
exp["status"],
]
)
td.render()
else:
ui.write("No experiment tasks in the queue.")
ui.write()
worker_status = self.repo.experiments.celery_queue.worker_status()
active_count = len(
[name for name, task in worker_status.items() if task]
)
idle_count = len(worker_status) - active_count
ui.write(f"Worker status: {active_count} active, {idle_count} idle")
return 0
def add_parser(queue_subparsers, parent_parser):
QUEUE_STATUS_HELP = (
"Show the status of experiments queue tasks and workers."
)
queue_status_parser = queue_subparsers.add_parser(
"status",
parents=[parent_parser],
description=append_doc_link(QUEUE_STATUS_HELP, "queue/status"),
help=QUEUE_STATUS_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
queue_status_parser.set_defaults(func=CmdQueueStatus)
+43
View File
@@ -0,0 +1,43 @@
import argparse
import logging
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.ui import ui
logger = logging.getLogger(__name__)
class CmdQueueStop(CmdBase):
"""Stop exp queue workers."""
def run(self):
self.repo.experiments.celery_queue.shutdown(kill=self.args.kill)
if self.args.kill:
ui.write(
"All running tasks in the queue have been killed."
"Queue workers are stopping."
)
else:
ui.write("Queue workers will stop after running tasks finish.")
return 0
def add_parser(queue_subparsers, parent_parser):
QUEUE_STOP_HELP = "Stop all experiments task queue workers."
queue_stop_parser = queue_subparsers.add_parser(
"stop",
parents=[parent_parser],
description=append_doc_link(QUEUE_STOP_HELP, "queue/stop"),
help=QUEUE_STOP_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
queue_stop_parser.add_argument(
"--kill",
action="store_true",
help="Kill all running tasks before stopping the queue workers.",
)
queue_stop_parser.set_defaults(func=CmdQueueStop)
+8
View File
@@ -18,6 +18,7 @@ class CmdUpdate(CmdBase):
rev=self.args.rev,
recursive=self.args.recursive,
to_remote=self.args.to_remote,
no_download=self.args.no_download,
remote=self.args.remote,
jobs=self.args.jobs,
)
@@ -55,6 +56,13 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Update all stages in the specified directory.",
)
update_parser.add_argument(
"--no-download",
action="store_true",
default=False,
help="Update .dvc file hash value(s)"
" but do not download the file(s).",
)
update_parser.add_argument(
"--to-remote",
action="store_true",
+3
View File
@@ -168,6 +168,7 @@ SCHEMA = {
"gs": {
"projectname": str,
"credentialpath": str,
"endpointurl": str,
Optional("verify", default=False): Bool,
**REMOTE_COMMON,
},
@@ -219,6 +220,7 @@ SCHEMA = {
**REMOTE_COMMON,
},
"gdrive": {
"profile": str,
"gdrive_use_service_account": Bool,
"gdrive_client_id": str,
"gdrive_client_secret": str,
@@ -226,6 +228,7 @@ SCHEMA = {
"gdrive_service_account_user_email": str,
"gdrive_service_account_json_file_path": str,
Optional("gdrive_trash_only", default=False): Bool,
Optional("gdrive_acknowledge_abuse", default=False): Bool,
Optional("verify", default=True): Bool,
**REMOTE_COMMON,
},
+5 -2
View File
@@ -6,6 +6,7 @@ import os
import platform
import sys
from subprocess import Popen
from typing import List
from dvc.env import DVC_DAEMON
from dvc.utils import fix_env, is_binary
@@ -99,12 +100,14 @@ def daemon(args):
Args:
args (list): list of arguments to append to `dvc daemon` command.
"""
daemonize(["daemon", "-q"] + args)
def daemonize(cmd: List[str]):
if os.environ.get(DVC_DAEMON):
logger.debug("skipping launching a new daemon.")
return
cmd = ["daemon", "-q"] + args
env = fix_env()
if not is_binary():
file_path = os.path.abspath(inspect.stack()[0][1])
+2 -5
View File
@@ -8,7 +8,7 @@ import dpath.util
from voluptuous import Any
from dvc.exceptions import DvcException
from dvc.utils.serialize import LOADERS, ParseError
from dvc.utils.serialize import ParseError, load_path
from dvc_data.hashfile.hash_info import HashInfo
from .base import Dependency
@@ -139,12 +139,9 @@ class ParamsDependency(Dependency):
)
def read_file(self):
_, ext = os.path.splitext(self.fs_path)
loader = LOADERS[ext]
self.validate_filepath()
try:
return loader(self.fs_path, fs=self.repo.fs)
return load_path(self.fs_path, self.repo.fs)
except ParseError as exc:
raise BadParamFileError(
f"Unable to read parameters from '{self}'"
+1 -3
View File
@@ -108,9 +108,7 @@ class RepoDependency(Dependency):
local_odb = self.repo.odb.local
locked = kwargs.pop("locked", True)
with self._make_repo(
locked=locked, cache_dir=local_odb.cache_dir
) as repo:
with self._make_repo(locked=locked, cache_dir=local_odb.path) as repo:
used_obj_ids = defaultdict(set)
rev = repo.get_rev()
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
+1 -1
View File
@@ -145,7 +145,7 @@ def _get_remote_config(url):
name = "auto-generated-upstream"
return {
"core": {"remote": name},
"remote": {name: {"url": repo.odb.local.cache_dir}},
"remote": {name: {"url": repo.odb.local.path}},
}
# Use original remote to make sure that we are using correct url,
+20 -23
View File
@@ -1,26 +1,18 @@
from urllib.parse import urlparse
from dvc_http import HTTPFileSystem, HTTPSFileSystem # noqa: F401
# pylint: disable=unused-import
from dvc_objects.fs import utils # noqa: F401
from dvc_objects.fs import ( # noqa: F401
FS_MAP,
AzureFileSystem,
GDriveFileSystem,
GSFileSystem,
HDFSFileSystem,
HTTPFileSystem,
HTTPSFileSystem,
LocalFileSystem,
MemoryFileSystem,
OSSFileSystem,
S3FileSystem,
Schemes,
SSHFileSystem,
WebDAVFileSystem,
WebDAVSFileSystem,
WebHDFSFileSystem,
generic,
get_fs_cls,
known_implementations,
localfs,
registry,
system,
)
from dvc_objects.fs.base import AnyFSPath, FileSystem # noqa: F401
@@ -29,18 +21,26 @@ from dvc_objects.fs.errors import ( # noqa: F401
ConfigError,
RemoteMissingDepsError,
)
from dvc_objects.fs.implementations.azure import AzureAuthError # noqa: F401
from dvc_objects.fs.implementations.gdrive import GDriveAuthError # noqa: F401
from dvc_objects.fs.implementations.local import localfs # noqa: F401
from dvc_objects.fs.implementations.ssh import ( # noqa: F401
DEFAULT_PORT as DEFAULT_SSH_PORT,
)
from dvc_objects.fs.path import Path # noqa: F401
from .data import DataFileSystem # noqa: F401
from .dvc import DvcFileSystem # noqa: F401
from .git import GitFileSystem # noqa: F401
known_implementations.update(
{
"dvc": {
"class": "dvc.fs.dvc.DvcFileSystem",
"err": "dvc is supported, but requires 'dvc' to be installed",
},
"git": {
"class": "dvc.fs.git.GitFileSystem",
"err": "git is supported, but requires 'dvc' to be installed",
},
}
)
# pylint: enable=unused-import
@@ -111,11 +111,8 @@ def get_cloud_fs(repo, **kwargs):
cls = get_fs_cls(remote_conf)
if cls == GDriveFileSystem and repo:
remote_conf["gdrive_credentials_tmp_dir"] = repo.tmp_dir
url = remote_conf.pop("url")
if issubclass(cls, WebDAVFileSystem):
if cls.protocol in ["webdav", "webdavs"]:
# For WebDAVFileSystem, provided url is the base path itself, so it
# should be treated as being a root path.
fs_path = cls.root_marker
+8 -173
View File
@@ -1,183 +1,16 @@
import logging
import os
import threading
import typing
from fsspec import AbstractFileSystem
from funcy import cached_property, wrap_prop
from dvc.utils import as_posix
from dvc_data.fs import DataFileSystem as _DataFileSystem
from dvc_objects.fs.base import FileSystem
from dvc_objects.fs.callbacks import DEFAULT_CALLBACK
if typing.TYPE_CHECKING:
from dvc.types import AnyPath
logger = logging.getLogger(__name__)
class _DataFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
"""DVC repo fs.
Args:
repo: DVC repo.
"""
root_marker = "/"
def __init__(self, workspace=None, **kwargs):
super().__init__(**kwargs)
self.repo = kwargs["repo"]
self.workspace = workspace or "repo"
@cached_property
def path(self):
from . import Path
def _getcwd():
return self.root_marker
return Path(self.sep, getcwd=_getcwd)
@property
def config(self):
raise NotImplementedError
def _get_key(self, path):
if self.workspace != "repo":
from . import get_cloud_fs
cls, kwargs, fs_path = get_cloud_fs(None, url=path)
fs = cls(**kwargs)
return (self.workspace, *fs.path.parts(fs_path))
path = self.path.abspath(path)
if path == self.root_marker:
return (self.workspace,)
key = self.path.relparts(path, self.root_marker)
if key == (".") or key == (""):
key = ()
return (self.workspace, *key)
def _get_fs_path(self, path: "AnyPath", remote=None):
from dvc.config import NoRemoteError
info = self.info(path)
if info["type"] == "directory":
raise IsADirectoryError
value = info.get("md5")
if not value:
raise FileNotFoundError
cache_path = self.repo.odb.local.oid_to_path(value)
if self.repo.odb.local.fs.exists(cache_path):
return self.repo.odb.local.fs, cache_path
try:
remote_odb = self.repo.cloud.get_remote_odb(remote)
except NoRemoteError as exc:
raise FileNotFoundError from exc
remote_fs_path = remote_odb.oid_to_path(value)
return remote_odb.fs, remote_fs_path
def open( # type: ignore
self, path: str, mode="r", encoding=None, **kwargs
): # pylint: disable=arguments-renamed, arguments-differ
fs, fspath = self._get_fs_path(path, **kwargs)
return fs.open(fspath, mode=mode, encoding=encoding)
def ls(self, path, detail=True, **kwargs):
info = self.info(path)
if info["type"] != "directory":
return [info] if detail else [path]
root_key = self._get_key(path)
try:
entries = [
self.sep.join((path, name)) if path else name
for name in self.repo.index.tree.ls(prefix=root_key)
]
except KeyError as exc:
raise FileNotFoundError from exc
if not detail:
return entries
return [self.info(epath) for epath in entries]
def isdvc(self, path, recursive=False, strict=True):
try:
info = self.info(path)
except FileNotFoundError:
return False
recurse = recursive or not strict
return bool(info.get("outs") if recurse else info.get("isout"))
def info(self, path, **kwargs):
from dvc_data.hashfile.meta import Meta
key = self._get_key(path)
try:
outs = list(self.repo.index.tree.iteritems(key)) # noqa: B301
except KeyError as exc:
raise FileNotFoundError from exc
ret = {
"type": "file",
"size": 0,
"isexec": False,
"isdvc": False,
"outs": outs,
"name": path,
}
if len(outs) > 1 and outs[0][0] != key:
shortest = self.repo.index.tree.shortest_prefix(key)
if shortest:
assert shortest[1][1].isdir
if len(shortest[0]) <= len(key):
ret["isdvc"] = True
ret["type"] = "directory"
return ret
item_key, (meta, hash_info) = outs[0]
meta = meta or Meta()
if key != item_key:
assert item_key[: len(key)] == key
ret["type"] = "directory"
return ret
ret["size"] = meta.size
ret["isexec"] = meta.isexec
ret[hash_info.name] = hash_info.value
ret["isdvc"] = True
ret["isout"] = True
ret["meta"] = meta
if hash_info and hash_info.isdir:
ret["type"] = "directory"
return ret
def get_file( # pylint: disable=arguments-differ
self, rpath, lpath, callback=DEFAULT_CALLBACK, **kwargs
):
fs, path = self._get_fs_path(rpath)
fs.get_file(path, lpath, callback=callback, **kwargs)
def checksum(self, path):
info = self.info(path)
md5 = info.get("md5")
if md5:
return md5
raise NotImplementedError
class DataFileSystem(FileSystem):
protocol = "local"
@@ -194,6 +27,8 @@ class DataFileSystem(FileSystem):
def isdvc(self, path, **kwargs):
return self.fs.isdvc(path, **kwargs)
@property
def repo(self):
return self.fs.repo
def from_os_path(self, path):
if os.path.isabs(path):
path = os.path.splitdrive(path)[1]
return as_posix(path)
+47 -49
View File
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
RepoFactory = Union[Callable[[str], "Repo"], Type["Repo"]]
Key = Tuple[str, ...]
def as_posix(path: str) -> str:
@@ -33,6 +34,14 @@ def _wrap_walk(dvc_fs, *args, **kwargs):
yield dvc_fs.path.join(dvc_fs.repo.root_dir, root), dnames, fnames
# NOT the same as dvc.dvcfile.is_dvc_file()!
def _is_dvc_file(fname):
from dvc.dvcfile import is_valid_filename
from dvc.ignore import DvcIgnore
return is_valid_filename(fname) or fname == DvcIgnore.DVCIGNORE_FILE
def _ls(fs, path):
dnames = []
fnames = []
@@ -135,14 +144,23 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
"""Keep a datafs instance of each repo."""
if hasattr(repo, "dvc_dir"):
self._datafss[key] = DataFileSystem(repo=repo)
self._datafss[key] = DataFileSystem(index=repo.index.data["repo"])
def _get_key(self, path):
def _get_key(self, path) -> Key:
parts = self.repo.fs.path.relparts(path, self.repo.root_dir)
if parts == (".",):
parts = ()
return parts
def _get_key_from_relative(self, path) -> Key:
parts = self.path.relparts(path, self.root_marker)
if parts and parts[0] == os.curdir:
parts = parts[1:]
return parts
def _from_key(self, parts: Key) -> str:
return self.repo.fs.path.join(self.repo.root_dir, *parts)
@property
def repo_url(self):
if self.repo is None:
@@ -155,9 +173,7 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
self.PARAM_REPO_URL: self.repo_url,
self.PARAM_REPO_ROOT: self.repo.root_dir,
self.PARAM_REV: getattr(self.repo.fs, "rev", None),
self.PARAM_CACHE_DIR: os.path.abspath(
self.repo.odb.local.cache_dir
),
self.PARAM_CACHE_DIR: os.path.abspath(self.repo.odb.local.path),
self.PARAM_CACHE_TYPES: self.repo.odb.local.cache_types,
self.PARAM_SUBREPOS: self._traverse_subrepos,
}
@@ -210,7 +226,7 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
) as repo:
return repo, factory
def _get_repo(self, path: str) -> "Repo":
def _get_repo(self, key: Key) -> "Repo":
"""Returns repo that the path falls in, using prefix.
If the path is already tracked/collected, it just returns the repo.
@@ -218,11 +234,6 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
Otherwise, it collects the repos that might be in the path's parents
and then returns the appropriate one.
"""
if not self.repo.fs.path.isin_or_eq(path, self.repo.root_dir):
# outside of repo
return self.repo
key = self._get_key(path)
repo = self._subrepos_trie.get(key)
if repo:
return repo
@@ -233,6 +244,7 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
*prefix_key, # pylint: disable=not-an-iterable
)
path = self._from_key(key)
parents = (parent for parent in self.repo.fs.path.parents(path))
dirs = [path] + list(takewhile(lambda p: p != prefix, parents))
dirs.reverse()
@@ -252,7 +264,9 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
scm=self.repo.scm,
repo_factory=self.repo_factory,
)
self._datafss[key] = DataFileSystem(repo=repo)
self._datafss[key] = DataFileSystem(
index=repo.index.data["repo"]
)
self._subrepos_trie[key] = repo
def _is_dvc_repo(self, dir_path):
@@ -268,6 +282,7 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
def _get_fs_pair(
self, path
) -> Tuple[
Optional["Repo"],
Optional[FileSystem],
Optional[str],
Optional[DataFileSystem],
@@ -276,30 +291,26 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
"""
Returns a pair of fss based on repo the path falls in, using prefix.
"""
parts = self.path.relparts(path, self.root_marker)
if parts and parts[0] == os.curdir:
parts = parts[1:]
key = self._get_key_from_relative(path)
fs_path = self.repo.fs.path.join(self.repo.root_dir, *parts)
repo = self._get_repo(fs_path)
fs_path = self._from_key(key)
repo = self._get_repo(key)
fs = repo.fs
if repo is self.repo:
dvc_parts = key
dvc_fs = self._datafss.get(())
else:
repo_parts = fs.path.relparts(repo.root_dir, self.repo.root_dir)
dvc_parts = key[len(repo_parts) :]
key = self._get_key(repo.root_dir)
dvc_fs = self._datafss.get(key)
repo_parts = fs.path.relparts(repo.root_dir, self.repo.root_dir)
if repo_parts[0] == os.curdir:
repo_parts = repo_parts[1:]
dvc_parts = parts[len(repo_parts) :]
if dvc_parts and dvc_parts[0] == os.curdir:
dvc_parts = dvc_parts[1:]
key = self._get_key(repo.root_dir)
dvc_fs = self._datafss.get(key)
if dvc_fs:
dvc_path = dvc_fs.path.join(*dvc_parts) if dvc_parts else ""
else:
dvc_path = None
return fs, fs_path, dvc_fs, dvc_path
return repo, fs, fs_path, dvc_fs, dvc_path
def open(
self, path, mode="r", encoding="utf-8", **kwargs
@@ -307,7 +318,7 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
if "b" in mode:
encoding = None
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
_, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
try:
return fs.open(fs_path, mode=mode, encoding=encoding)
except FileNotFoundError:
@@ -317,15 +328,14 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
return dvc_fs.open(dvc_path, mode=mode, encoding=encoding, **kwargs)
def isdvc(self, path, **kwargs):
_, _, dvc_fs, dvc_path = self._get_fs_pair(path)
_, _, _, dvc_fs, dvc_path = self._get_fs_pair(path)
return dvc_fs is not None and dvc_fs.isdvc(dvc_path, **kwargs)
def ls( # pylint: disable=arguments-differ
self, path, detail=True, dvc_only=False, **kwargs
):
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
repo, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
repo = dvc_fs.repo if dvc_fs else self.repo
dvcignore = repo.dvcignore
ignore_subrepos = kwargs.get("ignore_subrepos", True)
@@ -345,19 +355,8 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
pass
dvcfiles = kwargs.get("dvcfiles", False)
def _func(fname):
from dvc.dvcfile import is_valid_filename
from dvc.ignore import DvcIgnore
if dvcfiles:
return True
return not (
is_valid_filename(fname) or fname == DvcIgnore.DVCIGNORE_FILE
)
names = filter(_func, names)
if not dvcfiles:
names = (name for name in names if not _is_dvc_file(name))
infos = []
paths = []
@@ -378,7 +377,7 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
def get_file( # pylint: disable=arguments-differ
self, rpath, lpath, callback=DEFAULT_CALLBACK, **kwargs
):
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(rpath)
_, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(rpath)
if fs:
try:
@@ -390,9 +389,8 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
dvc_fs.get_file(dvc_path, lpath, callback=callback, **kwargs)
def info(self, path, **kwargs):
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
repo, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
repo = dvc_fs.repo if dvc_fs else self.repo
dvcignore = repo.dvcignore
ignore_subrepos = kwargs.get("ignore_subrepos", True)
@@ -434,7 +432,7 @@ class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
return info
def checksum(self, path):
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
_, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
try:
return fs.checksum(fs_path)
+13 -5
View File
@@ -8,7 +8,7 @@ import psutil
from dvc import __version__
from dvc.exceptions import NotDvcRepoError
from dvc.fs import FS_MAP, generic, get_fs_cls, get_fs_config
from dvc.fs import Schemes, generic, get_fs_cls, get_fs_config, registry
from dvc.repo import Repo
from dvc.scm import SCMError
from dvc.utils import error_link
@@ -32,9 +32,9 @@ 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.cache_dir):
if os.path.exists(repo.odb.local.path):
info.append(f"Cache types: {_get_linktype_support_info(repo)}")
fs_type = get_fs_type(repo.odb.local.cache_dir)
fs_type = get_fs_type(repo.odb.local.path)
info.append(f"Cache directory: {fs_type}")
else:
info.append("Cache types: " + error_link("no-dvc-cache"))
@@ -58,7 +58,7 @@ def _get_caches(cache):
caches = (
cache_type
for cache_type, cache_instance in cache.by_scheme()
if cache_instance
if cache_instance and cache_type != "repo"
)
# Caches will be always non-empty including the local cache
@@ -90,7 +90,15 @@ def _get_linktype_support_info(repo):
def _get_supported_remotes():
supported_remotes = []
for scheme, fs_cls in FS_MAP.items():
for scheme in registry:
if scheme in [Schemes.LOCAL, Schemes.MEMORY, "dvc", "git"]:
continue
try:
fs_cls = registry[scheme]
except ImportError:
continue
if not fs_cls.get_missing_deps():
dependencies = []
for requirement in fs_cls.REQUIRES:
+11
View File
@@ -70,6 +70,8 @@ class LockNoop(LockBase):
self._lock = True
def unlock(self):
if not self.is_locked:
raise DvcException("Unlock called on an unlocked lock")
self._lock = False
@property
@@ -93,6 +95,7 @@ class Lock(LockBase):
super().__init__(lockfile)
self._friendly = friendly
self._lock = None
self._lock_failed = False
@property
def files(self):
@@ -100,6 +103,7 @@ class Lock(LockBase):
def _do_lock(self):
try:
self._lock_failed = False
with Tqdm(
bar_format="{desc}",
disable=not self._friendly,
@@ -111,6 +115,7 @@ class Lock(LockBase):
):
self._lock = zc.lockfile.LockFile(self._lockfile)
except zc.lockfile.LockError:
self._lock_failed = True
raise LockError(FAILED_TO_LOCK_MESSAGE)
def lock(self):
@@ -120,6 +125,12 @@ class Lock(LockBase):
lock_retry()
def unlock(self):
if self._lock_failed:
assert self._lock is None
return
if not self.is_locked:
raise DvcException("Unlock called on an unlocked lock")
self._lock.close()
self._lock = None
+1 -1
View File
@@ -163,7 +163,7 @@ class LoggerHandler(logging.StreamHandler):
msg, file=self.stream, end=getattr(self, "terminator", "\n")
)
self.flush()
except RecursionError:
except (BrokenPipeError, RecursionError):
raise
except Exception: # noqa, pylint: disable=broad-except
self.handleError(record)
+2 -1
View File
@@ -3,7 +3,8 @@ from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator, Optional
if TYPE_CHECKING:
from dvc.fs import SSHFileSystem
from dvc_ssh import SSHFileSystem
from dvc.types import StrPath
+3 -2
View File
@@ -3,8 +3,9 @@ from contextlib import contextmanager
from functools import partial, partialmethod
from typing import TYPE_CHECKING, Iterator, Optional
from dvc_ssh import DEFAULT_PORT, SSHFileSystem
from dvc.exceptions import DvcException
from dvc.fs import DEFAULT_SSH_PORT, SSHFileSystem
from dvc.utils.fs import makedirs
from .base import BaseMachineBackend
@@ -68,7 +69,7 @@ class TerraformBackend(BaseMachineBackend):
resource = tpi.default_resource(name)
return {
"host": resource["instance_ip"],
"port": DEFAULT_SSH_PORT,
"port": DEFAULT_PORT,
"username": "ubuntu",
"fs_factory": partial(_sshfs, dict(resource)),
}
+13 -6
View File
@@ -1,8 +1,8 @@
from dvc.fs import Schemes
from dvc.fs import GitFileSystem, Schemes
from dvc_data.db import get_odb
def _get_odb(repo, settings):
def _get_odb(repo, settings, fs=None):
from dvc.fs import get_cloud_fs
if not settings:
@@ -10,7 +10,8 @@ def _get_odb(repo, settings):
cls, config, fs_path = get_cloud_fs(repo, **settings)
config["tmp_dir"] = repo.tmp_dir
return get_odb(cls(**config), fs_path, state=repo.state, **config)
fs = fs or cls(**config)
return get_odb(fs, fs_path, state=repo.state, **config)
class ODBManager:
@@ -24,7 +25,7 @@ class ODBManager:
]
def __init__(self, repo):
self.repo = repo
self._repo = repo
self.config = config = repo.config["cache"]
self._odb = {}
@@ -42,13 +43,19 @@ class ODBManager:
if opt in config:
settings[str(opt)] = config.get(opt)
self._odb[Schemes.LOCAL] = _get_odb(repo, settings)
kwargs = {}
if not isinstance(repo.fs, GitFileSystem):
kwargs["fs"] = repo.fs
odb = _get_odb(repo, settings, **kwargs)
self._odb["repo"] = odb
self._odb[Schemes.LOCAL] = odb
def _init_odb(self, schemes):
for scheme in schemes:
remote = self.config.get(scheme)
settings = {"name": remote} if remote else None
self._odb[scheme] = _get_odb(self.repo, settings)
self._odb[scheme] = _get_odb(self._repo, settings)
def __getattr__(self, name):
if name not in self._odb and name in self.CLOUD_SCHEMES:
+26 -29
View File
@@ -28,14 +28,7 @@ from dvc_data.hashfile.meta import Meta
from dvc_data.transfer import transfer as otransfer
from dvc_objects.errors import ObjectFormatError
from .fs import (
HDFSFileSystem,
LocalFileSystem,
RemoteMissingDepsError,
S3FileSystem,
Schemes,
get_cloud_fs,
)
from .fs import LocalFileSystem, RemoteMissingDepsError, Schemes, get_cloud_fs
from .utils import relpath
from .utils.fs import path_isin
@@ -65,10 +58,12 @@ CASE_SENSITIVE_CHECKSUM_SCHEMA = Any(
#
# so when a few types of outputs share the same name, we only need
# specify it once.
HDFS_PARAM_CHECKSUM = "checksum"
S3_PARAM_CHECKSUM = "etag"
CHECKSUMS_SCHEMA = {
LocalFileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
HDFSFileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
S3FileSystem.PARAM_CHECKSUM: CASE_SENSITIVE_CHECKSUM_SCHEMA,
HDFS_PARAM_CHECKSUM: CHECKSUM_SCHEMA,
S3_PARAM_CHECKSUM: CASE_SENSITIVE_CHECKSUM_SCHEMA,
}
@@ -341,22 +336,22 @@ class Output:
self.remote = remote
def _parse_path(self, fs, fs_path):
if fs.protocol != "local":
return fs_path
parsed = urlparse(self.def_path)
if parsed.scheme != "remote":
if (
parsed.scheme != "remote"
and self.stage
and self.stage.repo.fs == fs
and not fs.path.isabs(fs_path)
):
# NOTE: we can path either from command line or .dvc file,
# so we should expect both posix and windows style paths.
# paths accepts both, i.e. / works everywhere, \ only on win.
#
# FIXME: if we have Windows path containing / or posix one with \
# then we have #2059 bug and can't really handle that.
if self.stage and not os.path.isabs(fs_path):
fs_path = fs.path.join(self.stage.wdir, fs_path)
fs_path = fs.path.join(self.stage.wdir, fs_path)
abs_p = fs.path.abspath(fs.path.normpath(fs_path))
return abs_p
return fs.path.abspath(fs.path.normpath(fs_path))
def __repr__(self):
return "{class_name}: '{def_path}'".format(
@@ -380,15 +375,17 @@ class Output:
return self.fs.path.relpath(self.fs_path, self.repo.root_dir)
def clear(self):
self.hash_info = HashInfo.from_dict({})
self.meta = Meta.from_dict({})
self.obj = None
@property
def protocol(self):
return self.fs.protocol
@property
def is_in_repo(self):
if self.fs.protocol != "local":
return False
if urlparse(self.def_path).scheme == "remote":
return False
@@ -409,7 +406,8 @@ class Output:
@property
def odb(self):
odb = getattr(self.repo.odb, self.protocol)
odb_name = "repo" if self.is_in_repo else self.protocol
odb = getattr(self.repo.odb, odb_name)
if self.use_cache and odb is None:
raise RemoteCacheRequiredError(self.fs.protocol, self.fs_path)
return odb
@@ -545,7 +543,7 @@ class Output:
self.ignore()
if self.metric or self.plot:
if self.metric:
self.verify_metric()
if not self.use_cache:
@@ -708,22 +706,21 @@ class Output:
raise DvcException(
f"verify metric is not supported for {self.protocol}"
)
if not self.metric or self.plot:
if not self.metric:
return
if not os.path.exists(self.fs_path):
return
name = "metrics" if self.metric else "plot"
if os.path.isdir(self.fs_path):
msg = "directory '%s' cannot be used as %s."
logger.debug(msg, str(self), name)
logger.debug(msg, str(self), "metrics")
return
if not istextfile(self.fs_path, self.fs):
msg = "binary file '{}' cannot be used as {}."
raise DvcException(msg.format(self.fs_path, name))
raise DvcException(
f"binary file '{self.fs_path}' cannot be used as metrics."
)
def download(self, to, jobs=None):
from dvc.fs.callbacks import Callback
+2 -6
View File
@@ -1,5 +1,4 @@
import logging
import os
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Mapping, MutableMapping, MutableSequence, Sequence
@@ -357,17 +356,14 @@ class Context(CtxDict):
def load_from(
cls, fs, path: str, select_keys: List[str] = None
) -> "Context":
from dvc.utils.serialize import LOADERS
from dvc.utils.serialize import load_path
if not fs.exists(path):
raise ParamsLoadError(f"'{path}' does not exist")
if fs.isdir(path):
raise ParamsLoadError(f"'{path}' is a directory")
_, ext = os.path.splitext(path)
loader = LOADERS[ext]
data = loader(path, fs=fs)
data = load_path(path, fs)
if not isinstance(data, Mapping):
typ = type(data).__name__
raise ParamsLoadError(
+76 -23
View File
@@ -3,7 +3,7 @@ import os
from collections import defaultdict
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING, Callable, Optional
from typing import TYPE_CHECKING, Callable, List, Optional
from funcy import cached_property
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from dvc.fs import FileSystem
from dvc.repo.scm_context import SCMContext
from dvc.scm import Base
from dvc.stage import Stage
logger = logging.getLogger(__name__)
@@ -76,6 +77,8 @@ class Repo:
from dvc.repo.status import status # type: ignore[misc]
from dvc.repo.update import update # type: ignore[misc]
from .data import status as data_status # type: ignore[misc]
ls = staticmethod(_ls)
get = staticmethod(_get)
get_url = staticmethod(_get_url)
@@ -126,19 +129,17 @@ class Repo:
import hashlib
from dvc.utils.fs import makedirs
root_dir_hash = hashlib.sha224(
self.root_dir.encode("utf-8")
).hexdigest()
db_dir = os.path.join(
db_dir = self.fs.path.join(
base_db_dir,
self.DVC_DIR,
f"{os.path.basename(self.root_dir)}-{root_dir_hash[0:7]}",
f"{self.fs.path.name(self.root_dir)}-{root_dir_hash[0:7]}",
)
makedirs(db_dir, exist_ok=True)
self.fs.makedirs(db_dir, exist_ok=True)
return db_dir
def __init__(
@@ -155,7 +156,7 @@ class Repo:
):
from dvc.config import Config
from dvc.data_cloud import DataCloud
from dvc.fs import GitFileSystem, localfs
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
@@ -198,18 +199,23 @@ class Repo:
self.odb = ODBManager(self)
self.tmp_dir = None
else:
from dvc.utils.fs import makedirs
self.fs.makedirs(self.tmp_dir, exist_ok=True)
makedirs(self.tmp_dir, exist_ok=True)
self.lock = make_lock(
os.path.join(self.tmp_dir, "lock"),
tmp_dir=self.tmp_dir,
hardlink_lock=self.config["core"].get("hardlink_lock", False),
friendly=True,
)
if isinstance(self.fs, LocalFileSystem):
self.lock = make_lock(
self.fs.path.join(self.tmp_dir, "lock"),
tmp_dir=self.tmp_dir,
hardlink_lock=self.config["core"].get(
"hardlink_lock", False
),
friendly=True,
)
state_db_dir = self._get_database_dir("state")
self.state = State(self.root_dir, state_db_dir, self.dvcignore)
else:
self.lock = LockNoop()
self.state = StateNoop()
state_db_dir = self._get_database_dir("state")
self.state = State(self.root_dir, state_db_dir, self.dvcignore)
self.odb = ODBManager(self)
self.stage_cache = StageCache(self)
@@ -362,13 +368,13 @@ class Repo:
)
def unprotect(self, target):
return self.odb.local.unprotect(target)
return self.odb.repo.unprotect(target)
def _ignore(self):
flist = [self.config.files["local"], self.tmp_dir]
if path_isin(self.odb.local.cache_dir, self.root_dir):
flist += [self.odb.local.cache_dir]
if path_isin(self.odb.repo.path, self.root_dir):
flist += [self.odb.repo.path]
for file in flist:
self.scm_context.ignore(file)
@@ -386,12 +392,14 @@ class Repo:
all_tags=False,
all_commits=False,
all_experiments=False,
commit_date: Optional[str] = None,
remote=None,
force=False,
jobs=None,
recursive=False,
used_run_cache=None,
revs=None,
num=1,
):
"""Get the stages related to the given target and collect
the `info` of its outputs.
@@ -416,6 +424,8 @@ class Repo:
all_tags=all_tags,
all_commits=all_commits,
all_experiments=all_experiments,
commit_date=commit_date,
num=num,
):
for odb, objs in self.index.used_objs(
targets,
@@ -435,6 +445,44 @@ class Repo:
return used
def partial_imports(
self,
targets=None,
all_branches=False,
all_tags=False,
all_commits=False,
all_experiments=False,
commit_date: Optional[str] = None,
recursive=False,
revs=None,
num=1,
) -> List["Stage"]:
"""Get the stages related to the given target and collect dependencies
which are missing outputs.
This is useful to retrieve files which have been imported to the repo
using --no-download.
Returns:
A list of partially imported stages
"""
from itertools import chain
partial_imports = chain.from_iterable(
self.index.partial_imports(targets, recursive=recursive)
for _ in self.brancher(
revs=revs,
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
all_experiments=all_experiments,
commit_date=commit_date,
num=num,
)
)
return list(partial_imports)
@property
def stages(self): # obsolete, only for backward-compatibility
return self.index.stages
@@ -474,7 +522,7 @@ class Repo:
def datafs(self):
from dvc.fs.data import DataFileSystem
return DataFileSystem(repo=self)
return DataFileSystem(index=self.index.data["repo"])
@cached_property
def dvcfs(self):
@@ -495,18 +543,23 @@ class Repo:
from dvc.fs.dvc import DvcFileSystem
if os.path.isabs(path):
fs = DataFileSystem(repo=self, workspace="local")
fs = DataFileSystem(index=self.index.data["local"])
fs_path = path
else:
fs = DvcFileSystem(repo=self, subrepos=True)
fs_path = fs.from_os_path(path)
try:
if remote:
remote_odb = self.cloud.get_remote_odb(name=remote)
oid = fs.info(fs_path)["dvc_info"]["md5"]
fs = remote_odb.fs
fs_path = remote_odb.oid_to_path(oid)
with fs.open(
fs_path,
mode=mode,
encoding=encoding,
remote=remote,
) as fobj:
yield fobj
except FileNotFoundError as exc:
+12 -1
View File
@@ -1,3 +1,5 @@
from typing import Optional
from dvc.scm import iter_revs
@@ -8,7 +10,9 @@ def brancher( # noqa: E302
all_tags=False,
all_commits=False,
all_experiments=False,
commit_date: Optional[str] = None,
sha_only=False,
num=1,
):
"""Generator that iterates over specified revisions.
@@ -17,6 +21,9 @@ def brancher( # noqa: E302
all_branches (bool): iterate over all available branches.
all_commits (bool): iterate over all commits.
all_tags (bool): iterate over all available tags.
commit_date (str): Keep experiments from the commits after(include)
a certain date. Date must match the extended
ISO 8601 format (YYYY-MM-DD).
sha_only (bool): only return git SHA for a revision.
Yields:
@@ -62,6 +69,8 @@ def brancher( # noqa: E302
all_tags=all_tags,
all_commits=all_commits,
all_experiments=all_experiments,
commit_date=commit_date,
num=num,
)
try:
@@ -72,7 +81,9 @@ def brancher( # noqa: E302
self.root_dir = self.fs.path.join("/", *repo_root_parts)
if cwd_parts:
cwd = self.fs.path.join("/", *cwd_parts)
cwd = self.fs.path.join( # type: ignore[unreachable]
"/", *cwd_parts
)
self.fs.path.chdir(cwd)
# ignore revs that don't contain repo root
+295
View File
@@ -0,0 +1,295 @@
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, cast
from dvc.ui import ui
if TYPE_CHECKING:
from scmrepo.base import Base
from dvc.output import Output
from dvc.repo import Repo
from dvc_data.hashfile.db import HashFileDB
from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.hashfile.obj import HashFile
def _in_cache(obj: "HashInfo", cache: "HashFileDB") -> bool:
from dvc_objects.errors import ObjectFormatError
assert obj.value
try:
cache.check(obj.value)
return True
except (FileNotFoundError, ObjectFormatError):
return False
def _shallow_diff(
root: str,
old: Optional["HashInfo"],
new: Optional["HashInfo"],
cache: "HashFileDB",
) -> Dict[str, List[str]]:
d = {}
old_root = new_root = root
if old and old.isdir:
old_root = os.path.sep.join([root, ""])
if new and new.isdir:
new_root = os.path.sep.join([root, ""])
if old and not _in_cache(old, cache):
d["not_in_cache"] = [old_root]
if not old and not new:
return d
if not new:
return {"deleted": [old_root], **d}
if not old:
return {"added": [new_root], **d}
if old != new:
return {"modified": [new_root], **d}
return {"unchanged": [new_root], **d}
def _granular_diff(
root: str,
old_obj: Optional["HashFile"],
new_obj: Optional["HashFile"],
cache: "HashFileDB",
with_dirs: bool = False,
) -> Dict[str, List[str]]:
from dvc_data.diff import ROOT
from dvc_data.diff import diff as odiff
def path_join(root: str, *paths: str, isdir: bool = False) -> str:
if not isdir and paths == ROOT:
return root
return os.path.sep.join([root, *paths])
def type_changed(old, new):
if old.oid and new.oid:
return old.oid.isdir != new.oid.isdir
return False
diff_data = odiff(old_obj, new_obj, cache)
output: Dict[str, List[str]] = defaultdict(list)
for state in ("added", "deleted", "modified", "unchanged"):
items = getattr(diff_data, state)
for item in items: # pylint: disable=not-an-iterable
old = item.old
new = item.new
entry = old if state == "deleted" else new
isdir = entry.oid.isdir if entry.oid else False
obj_type_changed = type_changed(old, new)
if (
not with_dirs
and isdir
and entry.key == ROOT
and not obj_type_changed
):
continue
path = path_join(root, *entry.key, isdir=isdir)
output[state].append(path)
if not item.old.in_cache and state != "added":
output["not_in_cache"].append(path)
return dict(output)
def _get_obj_items(root: str, obj: Optional["HashFile"]) -> List[str]:
if not obj:
return []
from dvc_data.objects.tree import Tree
sep = os.path.sep
if isinstance(obj, Tree):
return [sep.join([root, *key]) for key, _, _ in obj]
return [root]
def _diff(
root: str,
old_oid: Optional["HashInfo"],
old_obj: Optional["HashFile"],
new_oid: Optional["HashInfo"],
new_obj: Optional["HashFile"],
odb: "HashFileDB",
with_dirs: bool = False,
granular: bool = False,
) -> Dict[str, List[str]]:
if not granular:
return _shallow_diff(root, old_oid, new_oid, odb)
if (old_oid and not old_obj) or (new_oid and not new_obj):
# we don't have enough information to give full details
unknown = _get_obj_items(root, new_obj or old_obj)
shallow_diff = _shallow_diff(root, old_oid, new_oid, odb)
return {**shallow_diff, "unknown": unknown}
return _granular_diff(root, old_obj, new_obj, odb, with_dirs=with_dirs)
class GitInfo(TypedDict, total=False):
staged: Dict[str, List[str]]
unstaged: Dict[str, List[str]]
untracked: List[str]
is_empty: bool
is_dirty: bool
def _git_info(scm: "Base", untracked_files: str = "all") -> GitInfo:
from scmrepo.exceptions import SCMError
from dvc.scm import NoSCM
if isinstance(scm, NoSCM):
return {}
try:
scm.get_rev()
except SCMError:
empty_repo = True
else:
empty_repo = False
staged, unstaged, untracked = scm.status(untracked_files=untracked_files)
# NOTE: order is important here.
return GitInfo(
staged=staged,
unstaged=unstaged,
untracked=untracked,
is_empty=empty_repo,
# TODO: fix is_dirty when untracked_files="no"
is_dirty=any([staged, unstaged, untracked]),
)
def _diff_index_to_wtree(repo: "Repo", **kwargs: Any) -> Dict[str, List[str]]:
from dvc_data.build import build
unstaged_diff = defaultdict(list)
for out in repo.index.outs:
out = cast("Output", out)
if not out.use_cache:
continue
try:
_, _, new = build(
out.odb,
out.fs_path,
out.fs,
out.fs.PARAM_CHECKSUM,
ignore=out.dvcignore,
dry_run=True,
)
except FileNotFoundError:
new = None
cache = repo.odb.repo
root = str(out)
old = out.get_obj()
with ui.status(f"Calculating diff for {root} between index/workspace"):
d = _diff(
root,
out.hash_info,
old,
new.hash_info if new else None,
new,
cache,
**kwargs,
)
for state, items in d.items():
if not items:
continue
unstaged_diff[state].extend(items)
return unstaged_diff
def _diff_head_to_index(
repo: "Repo", head: str = "HEAD", **kwargs: Any
) -> Dict[str, List[str]]:
# we need to store objects from index and the HEAD to diff later
objs: Dict[str, Dict[str, "HashFile"]] = defaultdict(dict)
staged_diff = defaultdict(list)
for rev in repo.brancher(revs=[head]):
for out in repo.index.outs:
out = cast("Output", out)
if not out.use_cache:
continue
root = str(out)
typ = "index" if rev == "workspace" else head
objs[root][typ] = (out.get_obj(), out.hash_info)
cache = repo.odb.repo
for root, obj_d in objs.items():
old_obj, old_oid = obj_d.get(head, (None, None))
new_obj, new_oid = obj_d.get("index", (None, None))
with ui.status(f"Calculating diff for {root} between head/index"):
d = _diff(
root, old_oid, old_obj, new_oid, new_obj, cache, **kwargs
)
for state, items in d.items():
if not items:
continue
staged_diff[state].extend(items)
return staged_diff
class Status(TypedDict):
not_in_cache: List[str]
committed: Dict[str, Any]
uncommitted: Dict[str, Any]
untracked: List[str]
unchanged: List[str]
git: GitInfo
def _transform_git_paths_to_dvc(repo: "Repo", files: List[str]):
"""Transform files rel. to Git root to DVC root, and drop outside files."""
rel = repo.fs.path.relpath(repo.root_dir, repo.scm.root_dir).rstrip("/")
if rel in (os.curdir, ""):
return files
prefix = rel + os.sep
length = len(prefix)
return [file[length:] for file in files if file.startswith(prefix)]
def status(repo: "Repo", untracked_files: str = "no", **kwargs: Any) -> Status:
from scmrepo.exceptions import SCMError
from dvc.scm import NoSCMError
head = kwargs.pop("head", "HEAD")
uncommitted_diff = _diff_index_to_wtree(repo, **kwargs)
not_in_cache = uncommitted_diff.pop("not_in_cache", [])
unchanged = set(uncommitted_diff.pop("unchanged", []))
try:
committed_diff = _diff_head_to_index(repo, head=head, **kwargs)
except (SCMError, NoSCMError):
committed_diff = {}
else:
# we don't care about not-in-cache between the head and the index.
committed_diff.pop("not_in_cache", None)
unchanged &= set(committed_diff.pop("unchanged", []))
git_info = _git_info(repo.scm, untracked_files=untracked_files)
untracked = git_info.get("untracked", [])
untracked = _transform_git_paths_to_dvc(repo, untracked)
# order matters here
return Status(
not_in_cache=not_in_cache,
committed=committed_diff,
uncommitted=uncommitted_diff,
untracked=untracked,
unchanged=list(unchanged),
git=git_info,
)
+6 -5
View File
@@ -140,9 +140,9 @@ def _output_paths(repo, targets):
if on_working_fs:
_, _, obj = build(
repo.odb.local,
repo.odb.repo,
output.fs_path,
repo.odb.local.fs,
repo.odb.repo.fs,
"md5",
dry_run=True,
ignore=output.dvcignore,
@@ -186,11 +186,12 @@ def _filter_missing(dvcfs, paths):
fs_path = dvcfs.from_os_path(path)
try:
info = dvcfs.info(fs_path)
dvc_info = info.get("dvc_info")
dvc_info = info.get("dvc_info", {})
entry = dvc_info.get("entry")
if (
dvc_info
entry
and info["type"] == "directory"
and not dvc_info["meta"].obj
and not entry.odb.exists(entry.hash_info.value)
):
yield path
except FileNotFoundError:
+191 -497
View File
@@ -1,74 +1,43 @@
import logging
import os
import re
from functools import wraps
from typing import Dict, Iterable, List, Mapping, Optional, Type
import time
from typing import Any, Dict, Iterable, Optional
from funcy import cached_property, first
from dvc.dependency.param import MissingParamsError
from dvc.env import DVCLIVE_RESUME
from dvc.exceptions import DvcException
from dvc.ui import ui
from dvc.utils import relpath
from .base import (
from .exceptions import (
BaselineMismatchError,
ExperimentExistsError,
InvalidExpRefError,
MultipleBranchError,
)
from .executor.base import BaseExecutor, ExecutorInfo
from .queue.base import BaseStashQueue, QueueEntry
from .queue.celery import LocalCeleryQueue
from .queue.tempdir import TempDirQueue
from .queue.workspace import WorkspaceQueue
from .refs import (
CELERY_FAILED_STASH,
CELERY_STASH,
EXEC_APPLY,
EXEC_BRANCH,
EXEC_CHECKPOINT,
EXEC_NAMESPACE,
EXPS_NAMESPACE,
EXPS_STASH,
BaselineMismatchError,
ExperimentExistsError,
WORKSPACE_STASH,
ExpRefInfo,
ExpStashEntry,
InvalidExpRefError,
MultipleBranchError,
)
from .executor.base import (
EXEC_PID_DIR,
EXEC_TMP_DIR,
BaseExecutor,
ExecutorInfo,
)
from .executor.manager.base import BaseExecutorManager
from .executor.manager.local import (
TempDirExecutorManager,
WorkspaceExecutorManager,
)
from .executor.manager.ssh import SSHExecutorManager
from .utils import exp_refs_by_rev
from .stash import ExpStashEntry
from .utils import exp_refs_by_rev, scm_locked, unlocked_repo
logger = logging.getLogger(__name__)
def scm_locked(f):
# Lock the experiments workspace so that we don't try to perform two
# different sequences of git operations at once
@wraps(f)
def wrapper(exp, *args, **kwargs):
from dvc.scm import map_scm_exception
with map_scm_exception(), exp.scm_lock:
return f(exp, *args, **kwargs)
return wrapper
def unlocked_repo(f):
@wraps(f)
def wrapper(exp, *args, **kwargs):
exp.repo.lock.unlock()
exp.repo._reset() # pylint: disable=protected-access
try:
ret = f(exp, *args, **kwargs)
finally:
exp.repo.lock.lock()
return ret
return wrapper
class Experiments:
"""Class that manages experiments in a DVC repo.
@@ -76,13 +45,6 @@ class Experiments:
repo (dvc.repo.Repo): repo instance that these experiments belong to.
"""
STASH_EXPERIMENT_FORMAT = "dvc-exp:{rev}:{baseline_rev}:{name}"
STASH_EXPERIMENT_RE = re.compile(
r"(?:commit: )"
r"dvc-exp:(?P<rev>[0-9a-f]+):(?P<baseline_rev>[0-9a-f]+)"
r":(?P<name>[^~^:\\?\[\]*]*)"
r"(:(?P<branch>.+))?$"
)
BRANCH_RE = re.compile(
r"^(?P<baseline_rev>[a-f0-9]{7})-(?P<exp_sha>[a-f0-9]+)"
r"(?P<checkpoint>-checkpoint)?$"
@@ -115,289 +77,34 @@ class Experiments:
return os.path.join(self.repo.tmp_dir, BaseExecutor.PACKED_ARGS_FILE)
@cached_property
def stash(self):
from scmrepo.git import Stash
def workspace_queue(self) -> WorkspaceQueue:
return WorkspaceQueue(self.repo, WORKSPACE_STASH)
return Stash(self.scm, EXPS_STASH)
@cached_property
def tempdir_queue(self) -> TempDirQueue:
# NOTE: tempdir and workspace stash is shared since both
# implementations immediately push -> pop (queue length is only 0 or 1)
return TempDirQueue(self.repo, WORKSPACE_STASH)
@cached_property
def celery_queue(self) -> LocalCeleryQueue:
return LocalCeleryQueue(self.repo, CELERY_STASH, CELERY_FAILED_STASH)
@property
def stash_revs(self) -> Dict[str, ExpStashEntry]:
revs = {}
for i, entry in enumerate(self.stash):
msg = entry.message.decode("utf-8").strip()
m = self.STASH_EXPERIMENT_RE.match(msg)
if m:
revs[entry.new_sha.decode("utf-8")] = ExpStashEntry(
i,
m.group("rev"),
m.group("baseline_rev"),
m.group("branch"),
m.group("name"),
)
for queue in (self.workspace_queue, self.celery_queue):
revs.update(queue.stash.stash_revs)
return revs
def _stash_exp(
self,
*args,
params: Optional[dict] = None,
resume_rev: Optional[str] = None,
baseline_rev: Optional[str] = None,
branch: Optional[str] = None,
name: Optional[str] = None,
**kwargs,
):
"""Stash changes from the workspace as an experiment.
Args:
params: Optional dictionary of parameter values to be used.
Values take priority over any parameters specified in the
user's workspace.
resume_rev: Optional checkpoint resume rev.
baseline_rev: Optional baseline rev for this experiment, defaults
to the current SCM rev.
branch: Optional experiment branch name. If specified, the
experiment will be added to `branch` instead of creating
a new branch.
name: Optional experiment name. If specified this will be used as
the human-readable name in the experiment branch ref. Has no
effect of branch is specified.
"""
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)
if resume_rev:
# move HEAD to the resume rev so that the stashed diff
# only contains changes relative to resume rev
stash_head = resume_rev
self.scm.set_ref(
"HEAD",
resume_rev,
message=f"dvc: resume from HEAD {resume_rev[:7]}",
)
self.scm.reset()
# update experiment params from command line
if params:
self._update_params(params)
# DVC commit data deps to preserve state across workspace
# & tempdir runs
self._stash_commit_deps(*args, **kwargs)
if resume_rev:
if branch:
branch_name = ExpRefInfo.from_ref(branch).name
else:
branch_name = f"{resume_rev[:7]}"
if self.scm.is_dirty(untracked_files=False):
logger.info(
"Modified checkpoint experiment based on "
"'%s' will be created",
branch_name,
)
branch = None
elif (
not branch
or self.scm.get_ref(branch) != resume_rev
):
msg = [
(
"Nothing to do for unchanged checkpoint "
f"'{resume_rev[:7]}'. "
)
]
if branch:
msg.append(
"To resume from the head of this "
"experiment, use "
f"'dvc exp apply {branch_name}'."
)
else:
names = [
ref_info.name
for ref_info in exp_refs_by_rev(
self.scm, resume_rev
)
]
if len(names) > 3:
names[3:] = [
f"... ({len(names) - 3} more)"
]
msg.append(
"To resume an experiment containing this "
"checkpoint, apply one of these heads:\n"
"\t{}".format(", ".join(names))
)
raise DvcException("".join(msg))
else:
logger.info(
"Existing checkpoint experiment '%s' will be "
"resumed",
branch_name,
)
if name:
logger.warning(
"Ignoring option '--name %s' for resumed "
"experiment. Existing experiment name will"
"be preserved instead.",
name,
)
# save additional repro command line arguments
run_env = {DVCLIVE_RESUME: "1"} if resume_rev else {}
self._pack_args(*args, run_env=run_env, **kwargs)
# save experiment as a stash commit
msg = self._stash_msg(
stash_head,
baseline_rev=baseline_rev,
branch=branch,
name=name,
)
stash_rev = self.stash.push(message=msg)
logger.debug(
(
"Stashed experiment '%s' with baseline '%s' "
"for future execution."
),
stash_rev[:7],
baseline_rev[:7],
)
finally:
if resume_rev:
# NOTE: this set_ref + reset() is equivalent to
# `git reset orig_head` (our SCM reset() only operates
# on HEAD rather than any arbitrary commit)
self.scm.set_ref(
"HEAD", orig_head, message="dvc: restore HEAD"
)
self.scm.reset()
# Revert any of our changes before prior unstashing
self.scm.reset(hard=True)
return stash_rev
def _stash_commit_deps(self, *args, **kwargs):
if len(args):
targets = args[0]
else:
targets = kwargs.get("targets")
if isinstance(targets, str):
targets = [targets]
elif not targets:
targets = [None]
for target in targets:
self.repo.commit(
target,
with_deps=True,
recursive=kwargs.get("recursive", False),
force=True,
allow_missing=True,
data_only=True,
)
def _stash_msg(
self,
rev: str,
baseline_rev: str,
branch: Optional[str] = None,
name: Optional[str] = None,
):
if not baseline_rev:
baseline_rev = rev
msg = self.STASH_EXPERIMENT_FORMAT.format(
rev=rev, baseline_rev=baseline_rev, name=name if name else ""
)
if branch:
return f"{msg}:{branch}"
return msg
def _pack_args(self, *args, **kwargs):
import pickle
if os.path.exists(self.args_file) and self.scm.is_tracked(
self.args_file
):
logger.warning(
(
"Temporary DVC file '.dvc/tmp/%s' exists and was "
"likely committed to Git by mistake. It should be removed "
"with:\n"
"\tgit rm .dvc/tmp/%s"
),
BaseExecutor.PACKED_ARGS_FILE,
BaseExecutor.PACKED_ARGS_FILE,
)
with open(self.args_file, "rb") as fobj:
try:
data = pickle.load(fobj)
except Exception: # pylint: disable=broad-except
data = {}
extra = int(data.get("extra", 0)) + 1
else:
extra = None
BaseExecutor.pack_repro_args(
self.args_file, *args, extra=extra, **kwargs
)
self.scm.add(self.args_file)
def _format_new_params_msg(self, new_params, config_path):
"""Format an error message for when new parameters are identified"""
new_param_count = len(new_params)
pluralise = "s are" if new_param_count > 1 else " is"
param_list = ", ".join(new_params)
return (
f"{new_param_count} parameter{pluralise} missing "
f"from '{config_path}': {param_list}"
)
def _update_params(self, params: dict):
"""Update experiment params files with the specified values."""
from dvc.utils.collections import NewParamsFound, merge_params
from dvc.utils.serialize import MODIFIERS
logger.debug("Using experiment params '%s'", params)
for path in params:
suffix = self.repo.fs.path.suffix(path).lower()
modify_data = MODIFIERS[suffix]
with modify_data(path, fs=self.repo.fs) as data:
try:
merge_params(data, params[path], allow_new=False)
except NewParamsFound as e:
msg = self._format_new_params_msg(e.new_params, path)
raise MissingParamsError(msg)
# Force params file changes to be staged in git
# Otherwise in certain situations the changes to params file may be
# ignored when we `git stash` them since mtime is used to determine
# whether the file is dirty
self.scm.add(list(params.keys()))
def reproduce_one(
self,
queue: bool = False,
tmp_dir: bool = False,
checkpoint_resume: Optional[str] = None,
reset: bool = False,
machine: Optional[str] = None,
**kwargs,
):
"""Reproduce and checkout a single experiment."""
if queue and not checkpoint_resume:
reset = True
if reset:
self.reset_checkpoints()
if not (queue or tmp_dir or machine):
"""Reproduce and checkout a single (standalone) experiment."""
if not (tmp_dir or machine):
staged, _, _ = self.scm.status(untracked_files="no")
if staged:
logger.warning(
@@ -406,6 +113,31 @@ class Experiments:
)
self.scm.reset()
exp_queue: BaseStashQueue = (
self.tempdir_queue if tmp_dir else self.workspace_queue
)
self.queue_one(exp_queue, **kwargs)
results = self._reproduce_queue(exp_queue)
exp_rev = first(results)
if exp_rev is not None:
self._log_reproduced(results, tmp_dir=tmp_dir)
return results
def queue_one(
self,
queue: BaseStashQueue,
checkpoint_resume: Optional[str] = None,
reset: bool = False,
**kwargs,
) -> QueueEntry:
"""Queue a single experiment."""
if reset:
self.reset_checkpoints()
if kwargs.pop("machine", None) is not None:
# TODO: decide how to handle queued remote execution
raise NotImplementedError
if checkpoint_resume:
from dvc.scm import resolve_rev
@@ -421,30 +153,12 @@ class Experiments:
else:
checkpoint_resume = self._workspace_resume_rev()
stash_rev = self.new(
checkpoint_resume=checkpoint_resume, reset=reset, **kwargs
return self.new(
queue,
checkpoint_resume=checkpoint_resume,
reset=reset,
**kwargs,
)
if queue:
logger.info(
"Queued experiment '%s' for future execution.", stash_rev[:7]
)
return [stash_rev]
if tmp_dir or queue:
manager_cls: Type = TempDirExecutorManager
elif machine:
manager_cls = SSHExecutorManager
else:
manager_cls = WorkspaceExecutorManager
results = self._reproduce_revs(
revs=[stash_rev],
keep_stash=False,
manager_cls=manager_cls,
machine=machine,
)
exp_rev = first(results)
if exp_rev is not None:
self._log_reproduced(results, tmp_dir=tmp_dir)
return results
def _workspace_resume_rev(self) -> Optional[str]:
last_checkpoint = self._get_last_checkpoint()
@@ -453,10 +167,46 @@ class Experiments:
return last_applied
return None
def reproduce_queued(self, **kwargs):
results = self._reproduce_revs(**kwargs)
def reproduce_celery(
self, entries: Optional[Iterable[QueueEntry]] = None, **kwargs
) -> Dict[str, str]:
results: Dict[str, str] = {}
if entries is None:
entries = list(self.celery_queue.iter_queued())
if not entries:
return results
# TODO: re-enable --jobs concurrency
self.celery_queue.spawn_worker()
failed = []
try:
ui.write(
"Following logs for all queued experiments. Use Ctrl+C to "
"stop following logs (experiment execution will continue).\n"
)
for entry in entries:
# wait for task execution to start
while not self.celery_queue.proc.get(entry.stash_rev):
time.sleep(1)
self.celery_queue.follow(entry)
# wait for task collection to complete
result = self.celery_queue.get_result(entry)
if result is None or result.exp_hash is None:
name = entry.name or entry.stash_rev[:7]
failed.append(name)
elif result.ref_info:
exp_rev = self.scm.get_ref(str(result.ref_info))
results[exp_rev] = result.exp_hash
except KeyboardInterrupt:
ui.write(
"Experiment(s) are still executing in the background. To "
"abort execution use 'dvc queue kill' or 'dvc queue stop'."
)
if failed:
names = ", ".join(name for name in failed)
ui.error(f"Failed to reproduce experiment(s) '{names}'")
if results:
self._log_reproduced(results, tmp_dir=True)
self._log_reproduced((rev for rev in results), True)
return results
def _log_reproduced(self, revs: Iterable[str], tmp_dir: bool = False):
@@ -464,18 +214,16 @@ class Experiments:
for rev in revs:
name = self.get_exact_name(rev)
names.append(name if name else rev[:7])
logger.info("\nReproduced experiment(s): %s", ", ".join(names))
ui.write("\nRan experiment(s): {}".format(", ".join(names)))
if tmp_dir:
logger.info(
ui.write(
"To apply the results of an experiment to your workspace "
"run:\n\n"
"\tdvc exp apply <exp>"
)
else:
logger.info(
"Experiment results have been applied to your workspace."
)
logger.info(
ui.write("Experiment results have been applied to your workspace.")
ui.write(
"\nTo promote an experiment to a Git branch run:\n\n"
"\tdvc exp branch <exp> <branch>\n"
)
@@ -492,15 +240,20 @@ class Experiments:
raise ExperimentExistsError(exp_ref.name)
@scm_locked
def new(self, *args, checkpoint_resume: Optional[str] = None, **kwargs):
"""Create a new experiment.
def new(
self,
queue: BaseStashQueue,
*args,
checkpoint_resume: Optional[str] = None,
**kwargs,
) -> QueueEntry:
"""Create and enqueue a new experiment.
Experiment will be reproduced and checked out into the user's
workspace.
Experiment will be derived from the current workspace.
"""
if checkpoint_resume is not None:
return self._resume_checkpoint(
*args, resume_rev=checkpoint_resume, **kwargs
queue, *args, resume_rev=checkpoint_resume, **kwargs
)
name = kwargs.get("name", None)
@@ -513,16 +266,16 @@ class Experiments:
if not (kwargs.get("force", False) or kwargs.get("reset", False)):
raise err
return self._stash_exp(*args, **kwargs)
return queue.put(*args, **kwargs)
def _resume_checkpoint(
self, *args, resume_rev: Optional[str] = None, **kwargs
):
"""Resume an existing (checkpoint) experiment.
Experiment will be reproduced and checked out into the user's
workspace.
"""
self,
queue: BaseStashQueue,
*args,
resume_rev: Optional[str] = None,
**kwargs,
) -> QueueEntry:
"""Create and queue a resumed checkpoint experiment."""
assert resume_rev
branch: Optional[str] = None
@@ -553,7 +306,7 @@ class Experiments:
resume_rev[:7],
baseline_rev,
)
return self._stash_exp(
return queue.put(
*args,
resume_rev=resume_rev,
baseline_rev=baseline_rev,
@@ -589,90 +342,25 @@ class Experiments:
self.scm.remove_ref(EXEC_CHECKPOINT)
self.scm.remove_ref(EXEC_APPLY)
@scm_locked
def _reproduce_revs(
self,
revs: Optional[Iterable] = None,
keep_stash: Optional[bool] = True,
manager_cls: Type = TempDirExecutorManager,
machine: Optional[str] = None,
**kwargs,
) -> Mapping[str, str]:
"""Reproduce the specified experiments.
@unlocked_repo
def _reproduce_queue(
self, queue: BaseStashQueue, **kwargs
) -> Dict[str, str]:
"""Reproduce queued experiments.
Args:
revs: If revs is not specified, all stashed experiments will be
reproduced.
keep_stash: If True, stashed experiments will be preserved if they
fail to reproduce successfully.
Arguments:
queue: Experiment queue.
Returns:
dict mapping successfully reproduced experiment revs to their
hashes.
results.
"""
stash_revs = self.stash_revs
exec_results = queue.reproduce()
# to_run contains mapping of:
# input_rev: (stash_index, rev, baseline_rev)
# where input_rev contains the changes to execute (usually a stash
# commit), rev is the original SCM commit to be checked out, and
# baseline_rev is the experiment baseline.
if revs is None:
to_run = dict(stash_revs)
else:
to_run = {
rev: stash_revs[rev]
if rev in stash_revs
else ExpStashEntry(None, rev, rev, None, None)
for rev in revs
}
logger.debug(
"Reproducing experiment revs '%s'",
", ".join(rev[:7] for rev in to_run),
)
manager = manager_cls.from_stash_entries(
self.scm,
os.path.join(self.repo.tmp_dir, EXEC_TMP_DIR),
self.repo,
to_run,
machine_name=machine,
)
try:
exec_results = {}
exec_results.update(self._executors_repro(manager, **kwargs))
finally:
# only drop successfully run stashed experiments
to_drop: List[int] = [
entry.stash_index
for rev, entry in to_run.items()
if (
entry.stash_index is not None
and (not keep_stash or rev in exec_results)
)
]
for index in sorted(to_drop, reverse=True):
self.stash.drop(index)
result: Dict[str, str] = {}
results: Dict[str, str] = {}
for _, exp_result in exec_results.items():
result.update(exp_result)
return result
@unlocked_repo
def _executors_repro(
self,
manager: "BaseExecutorManager",
**kwargs,
) -> Dict[str, Dict[str, str]]:
"""Run dvc repro for the specified BaseExecutors in parallel.
Returns:
dict mapping stash revs to the successfully executed experiments
for each stash rev.
"""
return manager.exec_queue(self.repo, **kwargs)
results.update(exp_result)
return results
def check_baseline(self, exp_rev):
baseline_sha = self.repo.scm.get_rev()
@@ -689,7 +377,6 @@ class Experiments:
return exp_baseline
raise BaselineMismatchError(exp_baseline, baseline_sha)
@scm_locked
def get_baseline(self, rev):
"""Return the baseline rev for an experiment rev."""
return self._get_baseline(rev)
@@ -742,61 +429,68 @@ class Experiments:
return self.stash_revs[rev].name
return None
def get_running_exps(self, fetch_refs: bool = True) -> Dict[str, int]:
def get_running_exps(self, fetch_refs: bool = True) -> Dict[str, Any]:
"""Return info for running experiments."""
result = {}
infofile = self.workspace_queue.get_infofile_path("workspace")
result.update(
self._fetch_running_exp("workspace", infofile, fetch_refs)
)
for queue in (self.tempdir_queue, self.celery_queue):
for entry in queue.iter_active():
infofile = queue.get_infofile_path(entry.stash_rev)
result.update(
self._fetch_running_exp(
entry.stash_rev, infofile, fetch_refs
)
)
return result
def _fetch_running_exp(
self, rev: str, infofile: str, fetch_refs: bool
) -> Dict[str, Any]:
from dvc.scm import InvalidRemoteSCMRepo
from dvc.utils.serialize import load_json
from .executor.local import TempDirExecutor
result = {}
pid_dir = os.path.join(
self.repo.tmp_dir,
EXEC_TMP_DIR,
EXEC_PID_DIR,
)
for fname in self.repo.fs.find(pid_dir):
rev, ext = os.path.splitext(os.path.basename(fname))
if ext != BaseExecutor.INFOFILE_EXT:
continue
try:
info = ExecutorInfo.from_dict(load_json(fname))
if info.result is not None:
continue
if rev == "workspace":
# If we are appending to a checkpoint branch in a workspace
# run, show the latest checkpoint as running.
last_rev = self.scm.get_ref(EXEC_BRANCH)
if last_rev:
result[last_rev] = info.asdict()
else:
result[rev] = info.asdict()
result: Dict[str, Any] = {}
try:
info = ExecutorInfo.from_dict(load_json(infofile))
except OSError:
return result
if info.result is None:
if rev == "workspace":
# If we are appending to a checkpoint branch in a workspace
# run, show the latest checkpoint as running.
last_rev = self.scm.get_ref(EXEC_BRANCH)
if last_rev:
result[last_rev] = info.asdict()
else:
result[rev] = info.asdict()
if info.git_url and fetch_refs:
else:
result[rev] = info.asdict()
if info.git_url and fetch_refs:
def on_diverged(_ref: str, _checkpoint: bool):
return False
def on_diverged(_ref: str, _checkpoint: bool):
return False
executor = TempDirExecutor.from_info(info)
try:
for ref in executor.fetch_exps(
self.scm,
on_diverged=on_diverged,
):
logger.debug(
"Updated running experiment '%s'.", ref
)
last_rev = self.scm.get_ref(ref)
result[rev]["last"] = last_rev
if last_rev:
result[last_rev] = info.asdict()
except InvalidRemoteSCMRepo:
# ignore stale info files
del result[rev]
except OSError:
pass
executor = TempDirExecutor.from_info(info)
try:
for ref in executor.fetch_exps(
self.scm,
on_diverged=on_diverged,
):
logger.debug(
"Updated running experiment '%s'.", ref
)
last_rev = self.scm.get_ref(ref)
result[rev]["last"] = last_rev
if last_rev:
result[last_rev] = info.asdict()
except InvalidRemoteSCMRepo:
# ignore stale info files
del result[rev]
return result
def apply(self, *args, **kwargs):
+2 -2
View File
@@ -7,13 +7,13 @@ from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.utils.fs import remove
from .base import (
EXEC_APPLY,
from .exceptions import (
ApplyConflictError,
BaselineMismatchError,
InvalidExpRevError,
)
from .executor.base import BaseExecutor
from .refs import EXEC_APPLY
if TYPE_CHECKING:
from scmrepo import Git
-161
View File
@@ -1,161 +0,0 @@
from typing import NamedTuple, Optional
from dvc.exceptions import DvcException, InvalidArgumentError
# Experiment refs are stored according baseline git SHA:
# refs/exps/01/234abcd.../<exp_name>
EXPS_NAMESPACE = "refs/exps"
EXPS_STASH = f"{EXPS_NAMESPACE}/stash"
EXEC_NAMESPACE = f"{EXPS_NAMESPACE}/exec"
EXEC_APPLY = f"{EXEC_NAMESPACE}/EXEC_APPLY"
EXEC_CHECKPOINT = f"{EXEC_NAMESPACE}/EXEC_CHECKPOINT"
EXEC_BRANCH = f"{EXEC_NAMESPACE}/EXEC_BRANCH"
EXEC_BASELINE = f"{EXEC_NAMESPACE}/EXEC_BASELINE"
EXEC_HEAD = f"{EXEC_NAMESPACE}/EXEC_HEAD"
EXEC_MERGE = f"{EXEC_NAMESPACE}/EXEC_MERGE"
class UnchangedExperimentError(DvcException):
def __init__(self, rev):
super().__init__(f"Experiment unchanged from '{rev[:7]}'.")
self.rev = rev
class BaselineMismatchError(DvcException):
def __init__(self, rev, expected):
if hasattr(rev, "hexsha"):
rev = rev.hexsha
rev_str = f"{rev[:7]}" if rev is not None else "invalid commit"
super().__init__(
f"Experiment derived from '{rev_str}', expected '{expected[:7]}'."
)
self.rev = rev
self.expected_rev = expected
class ExperimentExistsError(DvcException):
def __init__(self, name: str):
msg = (
"Experiment conflicts with existing experiment "
f"'{name}'. To overwrite the existing experiment run:\n\n"
"\tdvc exp run -f ...\n\n"
"To run this experiment with a different name run:\n\n"
f"\tdvc exp run -n <new_name> ...\n"
)
super().__init__(msg)
self.name = name
class CheckpointExistsError(DvcException):
def __init__(self, name: str):
msg = (
"Reproduced checkpoint experiment conflicts with existing "
f"experiment '{name}'. To restart (and overwrite) the existing "
"experiment run:\n\n"
"\tdvc exp run -f ...\n\n"
"To resume the existing experiment, run:\n\n"
f"\tdvc exp apply {name}\n"
f"\tdvc exp run\n"
)
super().__init__(msg)
self.name = name
class InvalidExpRefError(DvcException):
def __init__(self, ref):
super().__init__(f"'{ref}' is not a valid experiment refname.")
self.ref = ref
class InvalidExpRevError(InvalidArgumentError):
def __init__(self, rev):
super().__init__(
f"'{rev}' does not appear to be an experiment commit."
)
class MultipleBranchError(DvcException):
def __init__(self, rev, ref_infos):
super().__init__(
f"Ambiguous commit '{rev[:7]}' belongs to multiple experiment "
"branches."
)
self.rev = rev
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 ExpRefInfo:
namespace = EXPS_NAMESPACE
def __init__(
self, baseline_sha: Optional[str] = None, name: Optional[str] = None
):
self.baseline_sha = baseline_sha
self.name: str = name if name else ""
def __str__(self):
return "/".join(self.parts)
def __repr__(self):
baseline = f"'{self.baseline_sha}'" if self.baseline_sha else "None"
name = f"'{self.name}'" if self.name else "None"
return f"ExpRefInfo(baseline_sha={baseline}, name={name})"
@property
def parts(self):
return (
(self.namespace,)
+ (
(self.baseline_sha[:2], self.baseline_sha[2:])
if self.baseline_sha
else ()
)
+ ((self.name,) if self.name else ())
)
@classmethod
def from_ref(cls, ref: str):
try:
parts = ref.split("/")
if (
len(parts) < 2
or len(parts) == 3
or len(parts) > 5
or "/".join(parts[:2]) != EXPS_NAMESPACE
):
raise InvalidExpRefError(ref)
except ValueError:
raise InvalidExpRefError(ref)
baseline_sha = parts[2] + parts[3] if len(parts) >= 4 else None
name = parts[4] if len(parts) == 5 else None
return cls(baseline_sha, name)
class ExpStashEntry(NamedTuple):
"""Experiment stash entry.
stash_index: EXPS_STASH index for this entry. Can be None if this commit
is not pushed onto the stash ref.
head_rev: HEAD Git commit to be checked out for this experiment.
baseline_rev: Experiment baseline commit.
branch: Optional exp (checkpoint) branch name for this experiment.
"""
stash_index: Optional[int]
head_rev: str
baseline_rev: str
branch: Optional[str]
name: Optional[str]
+1 -1
View File
@@ -5,7 +5,7 @@ from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm import RevError
from .base import InvalidExpRevError
from .exceptions import InvalidExpRevError
from .utils import exp_refs_by_rev
logger = logging.getLogger(__name__)
+100 -8
View File
@@ -1,15 +1,97 @@
from typing import Collection, Iterable
from typing import TYPE_CHECKING, Collection, Iterable
from dvc.exceptions import InvalidArgumentError
from dvc.exceptions import DvcException, InvalidArgumentError
from .base import ExpRefInfo
if TYPE_CHECKING:
from .refs import ExpRefInfo
class UnchangedExperimentError(DvcException):
def __init__(self, rev):
super().__init__(f"Experiment unchanged from '{rev[:7]}'.")
self.rev = rev
class BaselineMismatchError(DvcException):
def __init__(self, rev, expected):
if hasattr(rev, "hexsha"):
rev = rev.hexsha
rev_str = f"{rev[:7]}" if rev is not None else "invalid commit"
super().__init__(
f"Experiment derived from '{rev_str}', expected '{expected[:7]}'."
)
self.rev = rev
self.expected_rev = expected
class ExperimentExistsError(DvcException):
def __init__(self, name: str):
msg = (
"Experiment conflicts with existing experiment "
f"'{name}'. To overwrite the existing experiment run:\n\n"
"\tdvc exp run -f ...\n\n"
"To run this experiment with a different name run:\n\n"
f"\tdvc exp run -n <new_name> ...\n"
)
super().__init__(msg)
self.name = name
class CheckpointExistsError(DvcException):
def __init__(self, name: str):
msg = (
"Reproduced checkpoint experiment conflicts with existing "
f"experiment '{name}'. To restart (and overwrite) the existing "
"experiment run:\n\n"
"\tdvc exp run -f ...\n\n"
"To resume the existing experiment, run:\n\n"
f"\tdvc exp apply {name}\n"
f"\tdvc exp run\n"
)
super().__init__(msg)
self.name = name
class InvalidExpRefError(DvcException):
def __init__(self, ref):
super().__init__(f"'{ref}' is not a valid experiment refname.")
self.ref = ref
class InvalidExpRevError(InvalidArgumentError):
def __init__(self, rev):
super().__init__(
f"'{rev}' does not appear to be an experiment commit."
)
class MultipleBranchError(DvcException):
def __init__(self, rev, ref_infos):
super().__init__(
f"Ambiguous commit '{rev[:7]}' belongs to multiple experiment "
"branches."
)
self.rev = rev
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,
exp_name: str,
exp_ref_list: Iterable[ExpRefInfo],
exp_ref_list: Iterable["ExpRefInfo"],
):
msg = [
(
@@ -23,21 +105,31 @@ class AmbiguousExpRefInfo(InvalidArgumentError):
class UnresolvedExpNamesError(InvalidArgumentError):
NAME = "experiment name"
def __init__(
self, unresolved_list: Collection[str], *args, git_remote: str = None
):
unresolved_names = ";".join(unresolved_list)
unresolved_names = "; ".join(unresolved_list)
if not git_remote:
if len(unresolved_names) > 1:
if len(unresolved_list) > 1:
super().__init__(
f"'{unresolved_names}' are not valid experiment names"
f"'{unresolved_names}' are not valid {self.NAME}s"
)
else:
super().__init__(
f"'{unresolved_names}' is not a valid experiment name"
f"'{unresolved_names}' is not a valid {self.NAME}"
)
else:
super().__init__(
f"Experiment '{unresolved_names}' does not exist "
f"in '{git_remote}'"
)
class UnresolvedQueueExpNamesError(UnresolvedExpNamesError):
NAME = "queued experiment name"
class ExpQueueEmptyError(DvcException):
pass
+15 -5
View File
@@ -22,20 +22,23 @@ from typing import (
from dvc.env import DVC_EXP_AUTO_PUSH, DVC_EXP_GIT_REMOTE
from dvc.exceptions import DvcException
from dvc.stage.serialize import to_lockfile
from dvc.ui import ui
from dvc.utils import dict_sha256, env2bool, relpath
from dvc.utils.fs import remove
from ..base import (
from ..exceptions import (
CheckpointExistsError,
ExperimentExistsError,
UnchangedExperimentError,
)
from ..refs import (
EXEC_BASELINE,
EXEC_BRANCH,
EXEC_CHECKPOINT,
EXEC_NAMESPACE,
EXPS_NAMESPACE,
EXPS_STASH,
CheckpointExistsError,
ExperimentExistsError,
ExpRefInfo,
UnchangedExperimentError,
)
if TYPE_CHECKING:
@@ -70,6 +73,7 @@ class ExecutorInfo:
dvc_dir: str
name: Optional[str] = None
wdir: Optional[str] = None
collected: bool = False
result_hash: Optional[str] = None
result_ref: Optional[str] = None
result_force: bool = False
@@ -99,6 +103,12 @@ class ExecutorInfo:
with modify_json(filename) as d:
d.update(self.asdict())
@classmethod
def load_json(cls, filename: str) -> "ExecutorInfo":
from dvc.utils.serialize import load_json
return cls.from_dict(load_json(filename))
_T = TypeVar("_T", bound="BaseExecutor")
@@ -628,7 +638,7 @@ class BaseExecutor(ABC):
if env2bool(DVC_EXP_AUTO_PUSH):
git_remote = os.getenv(DVC_EXP_GIT_REMOTE)
cls._auto_push(dvc, scm, git_remote)
logger.info("Checkpoint experiment iteration '%s'.", exp_rev[:7])
ui.write(f"Checkpoint experiment iteration '{exp_rev[:7]}'.")
except UnchangedExperimentError:
pass
+22 -8
View File
@@ -8,9 +8,9 @@ from funcy import cached_property
from scmrepo.exceptions import SCMError as _SCMError
from dvc.scm import SCM, GitMergeError
from dvc.utils.fs import remove
from dvc.utils.fs import makedirs, remove
from ..base import (
from ..refs import (
EXEC_APPLY,
EXEC_BASELINE,
EXEC_BRANCH,
@@ -26,7 +26,8 @@ if TYPE_CHECKING:
from dvc.repo import Repo
from ..base import ExpRefInfo, ExpStashEntry
from ..refs import ExpRefInfo
from ..stash import ExpStashEntry
logger = logging.getLogger(__name__)
@@ -64,7 +65,7 @@ class TempDirExecutor(BaseLocalExecutor):
# suggestions) that are not applicable outside of workspace runs
WARN_UNTRACKED = True
QUIET = True
DEFAULT_LOCATION = "temp"
DEFAULT_LOCATION = "tempdir"
def init_git(self, scm: "Git", branch: Optional[str] = None):
from dulwich.repo import Repo as DulwichRepo
@@ -89,6 +90,11 @@ class TempDirExecutor(BaseLocalExecutor):
head = EXEC_BRANCH if branch else EXEC_HEAD
self.scm.checkout(head, detach=True)
merge_rev = self.scm.get_ref(EXEC_MERGE)
for ref in (EXEC_HEAD, EXEC_MERGE, EXEC_BASELINE):
if scm.get_ref(ref):
scm.remove_ref(ref)
try:
self.scm.merge(merge_rev, squash=True, commit=False)
except _SCMError as exc:
@@ -106,7 +112,7 @@ class TempDirExecutor(BaseLocalExecutor):
def init_cache(self, repo: "Repo", rev: str, run_cache: bool = True):
"""Initialize DVC cache."""
self._config(repo.odb.local.cache_dir)
self._config(repo.odb.repo.path)
def cleanup(self):
super().cleanup()
@@ -119,11 +125,16 @@ class TempDirExecutor(BaseLocalExecutor):
repo: "Repo",
stash_rev: str,
entry: "ExpStashEntry",
wdir: Optional[str] = None,
**kwargs,
):
tmp_dir = mkdtemp(dir=os.path.join(repo.tmp_dir, EXEC_TMP_DIR))
parent_dir: str = wdir or os.path.join(repo.tmp_dir, EXEC_TMP_DIR)
makedirs(parent_dir, exist_ok=True)
tmp_dir = mkdtemp(dir=parent_dir)
try:
executor = cls._from_stash_entry(repo, stash_rev, entry, tmp_dir)
executor = cls._from_stash_entry(
repo, stash_rev, entry, tmp_dir, **kwargs
)
logger.debug("Init temp dir executor in '%s'", tmp_dir)
return executor
except Exception:
@@ -146,7 +157,9 @@ class WorkspaceExecutor(BaseLocalExecutor):
**kwargs,
):
root_dir = repo.scm.root_dir
executor = cls._from_stash_entry(repo, stash_rev, entry, root_dir)
executor = cls._from_stash_entry(
repo, stash_rev, entry, root_dir, **kwargs
)
logger.debug("Init workspace executor in '%s'", root_dir)
return executor
@@ -174,6 +187,7 @@ class WorkspaceExecutor(BaseLocalExecutor):
def cleanup(self):
with self._detach_stack:
self.scm.remove_ref(EXEC_BASELINE)
self.scm.remove_ref(EXEC_MERGE)
if self.scm.get_ref(EXEC_BRANCH):
self.scm.remove_ref(EXEC_BRANCH)
checkpoint = self.scm.get_ref(EXEC_CHECKPOINT)
@@ -7,15 +7,9 @@ from typing import TYPE_CHECKING, Deque, Dict, Generator, Optional, Tuple, Type
from dvc.proc.manager import ProcessManager
from ...base import (
EXEC_BASELINE,
EXEC_HEAD,
EXEC_MERGE,
CheckpointExistsError,
ExperimentExistsError,
ExpRefInfo,
ExpStashEntry,
)
from ...exceptions import CheckpointExistsError, ExperimentExistsError
from ...refs import EXEC_BASELINE, EXEC_HEAD, EXEC_MERGE, ExpRefInfo
from ...stash import ExpStashEntry
from ..base import EXEC_PID_DIR, BaseExecutor
from ..local import TempDirExecutor, WorkspaceExecutor
@@ -2,13 +2,8 @@ import logging
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, Optional
from ...base import (
EXEC_BASELINE,
EXEC_BRANCH,
EXEC_HEAD,
EXEC_MERGE,
ExpStashEntry,
)
from ...refs import EXEC_BASELINE, EXEC_BRANCH, EXEC_HEAD, EXEC_MERGE
from ...stash import ExpStashEntry
from ..local import TempDirExecutor, WorkspaceExecutor
from .base import BaseExecutorManager
+1 -1
View File
@@ -3,7 +3,7 @@ import posixpath
from collections import defaultdict
from typing import TYPE_CHECKING, Callable, Dict, Generator, Optional, Tuple
from ...base import ExpStashEntry
from ...stash import ExpStashEntry
from ..base import BaseExecutor
from ..ssh import SSHExecutor, _sshfs
from .base import BaseExecutorManager
+2 -3
View File
@@ -5,17 +5,16 @@ import sys
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, Iterable, Optional
from dvc_ssh import SSHFileSystem
from funcy import first
from dvc.fs import SSHFileSystem
from dvc.repo.experiments.base import (
from ..refs import (
EXEC_BRANCH,
EXEC_CHECKPOINT,
EXEC_HEAD,
EXEC_MERGE,
EXEC_NAMESPACE,
)
from .base import BaseExecutor, ExecutorInfo, ExecutorResult
if TYPE_CHECKING:
+7 -4
View File
@@ -15,6 +15,7 @@ def gc(
all_tags: Optional[bool] = False,
all_commits: Optional[bool] = False,
workspace: Optional[bool] = False,
commit_date: Optional[str] = None,
queued: Optional[bool] = False,
):
keep_revs = set(
@@ -22,6 +23,7 @@ def gc(
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
commit_date=commit_date,
sha_only=True,
)
)
@@ -40,11 +42,12 @@ def gc(
removed = len(to_remove)
delete_stashes = []
for _, entry in repo.experiments.stash_revs.items():
stash = repo.experiments.celery_queue.stash
for stash_rev, entry in stash.stash_revs.items():
if not queued or entry.baseline_rev not in keep_revs:
delete_stashes.append(entry.stash_index)
for index in sorted(delete_stashes, reverse=True):
repo.experiments.stash.drop(index)
delete_stashes.append(stash_rev)
if delete_stashes:
repo.experiments.celery_queue.remove(delete_stashes)
removed += len(delete_stashes)
return removed
+12 -5
View File
@@ -253,18 +253,25 @@ def init(
live_metrics = f"{live_path}.json" if live_path else None
live_plots = os.path.join(live_path, "scalars") if live_path else None
if type == "checkpoint":
outs_key = "checkpoints"
metrics_key = "metrics_persist_no_cache"
plots_key = "plots_persist_no_cache"
else:
outs_key = "outs"
metrics_key = "metrics_no_cache"
plots_key = "plots_no_cache"
stage = repo.stage.create(
name=name,
cmd=context["cmd"],
deps=compact([context.get("code"), context.get("data")]),
params=[{params: None}] if params else None,
metrics_no_cache=compact([context.get("metrics"), live_metrics]),
plots_no_cache=compact([context.get("plots"), live_plots]),
force=force,
**{
"checkpoints"
if type == "checkpoint"
else "outs": compact([models])
outs_key: compact([models]),
metrics_key: compact([context.get("metrics"), live_metrics]),
plots_key: compact([context.get("plots"), live_plots]),
},
)
+1 -1
View File
@@ -9,8 +9,8 @@ from dvc.repo.scm_context import scm_context
from dvc.scm import TqdmGit, iter_revs
from dvc.ui import ui
from .base import ExpRefInfo
from .exceptions import UnresolvedExpNamesError
from .refs import ExpRefInfo
from .utils import exp_commits, exp_refs, exp_refs_by_baseline, resolve_name
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -9,8 +9,8 @@ from dvc.repo.scm_context import scm_context
from dvc.scm import TqdmGit, iter_revs
from dvc.ui import ui
from .base import ExpRefInfo
from .exceptions import UnresolvedExpNamesError
from .refs import ExpRefInfo
from .utils import exp_commits, exp_refs, exp_refs_by_baseline, resolve_name
logger = logging.getLogger(__name__)
+668
View File
@@ -0,0 +1,668 @@
import logging
import os
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
from typing import (
TYPE_CHECKING,
Any,
Collection,
Dict,
Generator,
Iterable,
List,
Mapping,
NamedTuple,
Optional,
Type,
Union,
)
from funcy import cached_property
from dvc.env import DVCLIVE_RESUME
from dvc.exceptions import DvcException
from dvc.ui import ui
from ..exceptions import CheckpointExistsError, ExperimentExistsError
from ..executor.base import (
EXEC_PID_DIR,
EXEC_TMP_DIR,
BaseExecutor,
ExecutorResult,
)
from ..executor.local import WorkspaceExecutor
from ..refs import EXEC_BASELINE, EXEC_HEAD, EXEC_MERGE, ExpRefInfo
from ..stash import ExpStash, ExpStashEntry
from ..utils import exp_refs_by_rev, scm_locked
if TYPE_CHECKING:
from scmrepo.git import Git
from dvc.repo import Repo
from dvc.repo.experiments import Experiments
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class QueueEntry:
dvc_root: str
scm_root: str
stash_ref: str
stash_rev: str
baseline_rev: str
branch: Optional[str]
name: Optional[str]
head_rev: Optional[str] = None
def __eq__(self, other: object):
return (
isinstance(other, QueueEntry)
and self.dvc_root == other.dvc_root
and self.scm_root == other.scm_root
and self.stash_ref == other.stash_ref
and self.stash_rev == other.stash_rev
)
def asdict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "QueueEntry":
return cls(**d)
class QueueGetResult(NamedTuple):
entry: QueueEntry
executor: BaseExecutor
class QueueDoneResult(NamedTuple):
entry: QueueEntry
result: Optional[ExecutorResult]
class BaseStashQueue(ABC):
"""Naive Git-stash based experiment queue.
Maps queued experiments to (Git) stash reflog entries.
"""
def __init__(
self, repo: "Repo", ref: str, failed_ref: Optional[str] = None
):
"""Construct a queue.
Arguments:
scm: Git SCM instance for this queue.
ref: Git stash ref for this queue.
failed_ref: Failed run Git stash ref for this queue.
"""
self.repo = repo
self.ref = ref
self.failed_ref = failed_ref
@property
def scm(self) -> "Git":
return self.repo.scm
@cached_property
def stash(self) -> ExpStash:
return ExpStash(self.scm, self.ref)
@cached_property
def failed_stash(self) -> Optional[ExpStash]:
return ExpStash(self.scm, self.failed_ref) if self.failed_ref else None
@cached_property
def pid_dir(self) -> str:
return os.path.join(self.repo.tmp_dir, EXEC_TMP_DIR, EXEC_PID_DIR)
@cached_property
def args_file(self):
return os.path.join(self.repo.tmp_dir, BaseExecutor.PACKED_ARGS_FILE)
@abstractmethod
def put(self, *args, **kwargs) -> QueueEntry:
"""Stash an experiment and add it to the queue."""
@abstractmethod
def get(self) -> QueueGetResult:
"""Pop and return the first item in the queue."""
def remove(
self,
revs: Collection[str],
all_: bool = False,
queued: bool = False,
**kwargs,
) -> List[str]:
"""Remove the specified entries from the queue.
Arguments:
revs: Stash revisions or queued exp names to be removed.
queued: Remove all queued tasks.
all: Remove all tasks.
Returns:
Revisions (or names) which were removed.
"""
if all_ or queued:
return self.clear()
name_to_remove: List[str] = []
entry_to_remove: List[ExpStashEntry] = []
queue_entries = self.match_queue_entry_by_name(
revs, self.iter_queued()
)
for name, entry in queue_entries.items():
if entry:
entry_to_remove.append(self.stash.stash_revs[entry.stash_rev])
name_to_remove.append(name)
self.stash.remove_revs(entry_to_remove)
return name_to_remove
def clear(self, **kwargs) -> List[str]:
"""Remove all entries from the queue."""
stash_revs = self.stash.stash_revs
name_to_remove = list(stash_revs)
self.stash.remove_revs(list(stash_revs.values()))
return name_to_remove
def status(self) -> List[Dict[str, Any]]:
"""Show the status of exp tasks in queue"""
from datetime import datetime
result: List[Dict[str, Optional[str]]] = []
def _get_timestamp(rev: str) -> datetime:
commit = self.scm.resolve_commit(rev)
return datetime.fromtimestamp(commit.commit_time)
def _format_entry(
entry: QueueEntry,
exp_result: Optional[ExecutorResult] = None,
status: str = "Unknown",
) -> Dict[str, Any]:
name = entry.name
if not name and exp_result and exp_result.ref_info:
name = exp_result.ref_info.name
# NOTE: We fallback to Unknown status for experiments
# generated in prior (incompatible) DVC versions
return {
"rev": entry.stash_rev,
"name": name,
"timestamp": _get_timestamp(entry.stash_rev),
"status": status,
}
result.extend(
_format_entry(queue_entry, status="Running")
for queue_entry in self.iter_active()
)
result.extend(
_format_entry(queue_entry, status="Queued")
for queue_entry in self.iter_queued()
)
result.extend(
_format_entry(queue_entry, status="Failed")
for queue_entry, _ in self.iter_failed()
)
result.extend(
_format_entry(queue_entry, exp_result=exp_result, status="Success")
for queue_entry, exp_result in self.iter_success()
)
return result
@abstractmethod
def iter_queued(self) -> Generator[QueueEntry, None, None]:
"""Iterate over items in the queue."""
@abstractmethod
def iter_active(self) -> Generator[QueueEntry, None, None]:
"""Iterate over items which are being actively processed."""
@abstractmethod
def iter_done(self) -> Generator[QueueDoneResult, None, None]:
"""Iterate over items which been processed."""
@abstractmethod
def iter_success(self) -> Generator[QueueDoneResult, None, None]:
"""Iterate over items which been success."""
@abstractmethod
def iter_failed(self) -> Generator[QueueDoneResult, None, None]:
"""Iterate over items which been failed."""
@abstractmethod
def reproduce(self) -> Mapping[str, Mapping[str, str]]:
"""Reproduce queued experiments sequentially."""
@abstractmethod
def get_result(self, entry: QueueEntry) -> Optional[ExecutorResult]:
"""Return result of the specified item.
This method blocks until the specified item has been collected.
"""
@abstractmethod
def kill(self, revs: str) -> None:
"""Kill the specified running entries in the queue.
Arguments:
revs: Stash revs or running exp name to be killed.
"""
@abstractmethod
def shutdown(self, kill: bool = False):
"""Shutdown the queue worker.
Arguments:
kill: If True, the any active experiments will be killed and the
worker will shutdown immediately. If False, the worker will
finish any active experiments before shutting down.
"""
def logs(
self,
rev: str,
encoding: Optional[str] = None,
follow: bool = False,
):
"""Print redirected output logs for an exp process.
Args:
rev: Stash rev or exp name.
encoding: Text encoding for redirected output. Defaults to
`locale.getpreferredencoding()`.
follow: Attach to running exp process and follow additional
output.
"""
def _stash_exp(
self,
*args,
params: Optional[Dict[str, List[str]]] = None,
resume_rev: Optional[str] = None,
baseline_rev: Optional[str] = None,
branch: Optional[str] = None,
name: Optional[str] = None,
**kwargs,
) -> QueueEntry:
"""Stash changes from the workspace as an experiment.
Args:
params: Dict mapping paths to `Hydra Override`_ patterns,
provided via `exp run --set-param`.
resume_rev: Optional checkpoint resume rev.
baseline_rev: Optional baseline rev for this experiment, defaults
to the current SCM rev.
branch: Optional experiment branch name. If specified, the
experiment will be added to `branch` instead of creating
a new branch.
name: Optional experiment name. If specified this will be used as
the human-readable name in the experiment branch ref. Has no
effect of branch is specified.
.. _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() as workspace:
try:
if workspace:
self.stash.apply(workspace)
if resume_rev:
# move HEAD to the resume rev so that the stashed diff
# only contains changes relative to resume rev
stash_head = resume_rev
self.scm.set_ref(
"HEAD",
resume_rev,
message=f"dvc: resume from HEAD {resume_rev[:7]}",
)
self.scm.reset()
# update experiment params from command line
if params:
self._update_params(params)
# DVC commit data deps to preserve state across workspace
# & tempdir runs
self._stash_commit_deps(*args, **kwargs)
if resume_rev:
if branch:
branch_name = ExpRefInfo.from_ref(branch).name
else:
branch_name = f"{resume_rev[:7]}"
if self.scm.is_dirty(untracked_files=False):
ui.write(
"Modified checkpoint experiment based on "
f"'{branch_name}' will be created",
)
branch = None
elif (
not branch
or self.scm.get_ref(branch) != resume_rev
):
err_msg = [
(
"Nothing to do for unchanged checkpoint "
f"'{resume_rev[:7]}'. "
)
]
if branch:
err_msg.append(
"To resume from the head of this "
"experiment, use "
f"'dvc exp apply {branch_name}'."
)
else:
names = [
ref_info.name
for ref_info in exp_refs_by_rev(
self.scm, resume_rev
)
]
if len(names) > 3:
names[3:] = [
f"... ({len(names) - 3} more)"
]
err_msg.append(
"To resume an experiment containing this "
"checkpoint, apply one of these heads:\n"
"\t{}".format(", ".join(names))
)
raise DvcException("".join(err_msg))
else:
ui.write(
"Existing checkpoint experiment "
f"'{branch_name}' will be resumed"
)
if name:
logger.warning(
"Ignoring option '--name %s' for resumed "
"experiment. Existing experiment name will"
"be preserved instead.",
name,
)
# save additional repro command line arguments
run_env = {DVCLIVE_RESUME: "1"} if resume_rev else {}
self._pack_args(*args, run_env=run_env, **kwargs)
# save experiment as a stash commit
msg = self._stash_msg(
stash_head,
baseline_rev=baseline_rev,
branch=branch,
name=name,
)
stash_rev = self.stash.push(message=msg)
logger.debug(
(
"Stashed experiment '%s' with baseline '%s' "
"for future execution."
),
stash_rev[:7],
baseline_rev[:7],
)
finally:
if resume_rev:
# NOTE: this set_ref + reset() is equivalent to
# `git reset orig_head` (our SCM reset() only operates
# on HEAD rather than any arbitrary commit)
self.scm.set_ref(
"HEAD", orig_head, message="dvc: restore HEAD"
)
self.scm.reset()
# Revert any of our changes before prior unstashing
self.scm.reset(hard=True)
return QueueEntry(
self.repo.root_dir,
self.scm.root_dir,
self.ref,
stash_rev,
baseline_rev,
branch,
name,
stash_head,
)
def _stash_commit_deps(self, *args, **kwargs):
if len(args):
targets = args[0]
else:
targets = kwargs.get("targets")
if isinstance(targets, str):
targets = [targets]
elif not targets:
targets = [None]
for target in targets:
self.repo.commit(
target,
with_deps=True,
recursive=kwargs.get("recursive", False),
force=True,
allow_missing=True,
data_only=True,
)
@staticmethod
def _stash_msg(
rev: str,
baseline_rev: str,
branch: Optional[str] = None,
name: Optional[str] = None,
) -> str:
if not baseline_rev:
baseline_rev = rev
msg = ExpStash.format_message(rev, baseline_rev, name)
if branch:
return f"{msg}:{branch}"
return msg
def _pack_args(self, *args, **kwargs) -> None:
import pickle
if os.path.exists(self.args_file) and self.scm.is_tracked(
self.args_file
):
logger.warning(
(
"Temporary DVC file '.dvc/tmp/%s' exists and was "
"likely committed to Git by mistake. It should be removed "
"with:\n"
"\tgit rm .dvc/tmp/%s"
),
BaseExecutor.PACKED_ARGS_FILE,
BaseExecutor.PACKED_ARGS_FILE,
)
with open(self.args_file, "rb") as fobj:
try:
data = pickle.load(fobj)
except Exception: # pylint: disable=broad-except
data = {}
extra = int(data.get("extra", 0)) + 1
else:
extra = None
BaseExecutor.pack_repro_args(
self.args_file, *args, extra=extra, **kwargs
)
self.scm.add(self.args_file)
@staticmethod
def _format_new_params_msg(new_params, config_path):
"""Format an error message for when new parameters are identified"""
new_param_count = len(new_params)
pluralise = "s are" if new_param_count > 1 else " is"
param_list = ", ".join(new_params)
return (
f"{new_param_count} parameter{pluralise} missing "
f"from '{config_path}': {param_list}"
)
def _update_params(self, params: Dict[str, List[str]]):
"""Update param files with the provided `Hydra Override`_ patterns.
Args:
params: Dict mapping paths to `Hydra Override`_ patterns,
provided via `exp run --set-param`.
.. _Hydra Override:
https://hydra.cc/docs/next/advanced/override_grammar/basic/
"""
logger.debug("Using experiment params '%s'", params)
from dvc.utils.hydra import apply_overrides
for path, overrides in params.items():
apply_overrides(path, overrides)
# Force params file changes to be staged in git
# Otherwise in certain situations the changes to params file may be
# ignored when we `git stash` them since mtime is used to determine
# whether the file is dirty
self.scm.add(list(params.keys()))
@staticmethod
@scm_locked
def setup_executor(
exp: "Experiments",
queue_entry: QueueEntry,
executor_cls: Type[BaseExecutor] = WorkspaceExecutor,
**kwargs,
) -> BaseExecutor:
scm = exp.scm
stash = ExpStash(scm, queue_entry.stash_ref)
stash_rev = queue_entry.stash_rev
stash_entry = stash.stash_revs.get(
stash_rev,
ExpStashEntry(None, stash_rev, stash_rev, None, None),
)
if stash_entry.stash_index is not None:
stash.drop(stash_entry.stash_index)
scm.set_ref(EXEC_HEAD, stash_entry.head_rev)
scm.set_ref(EXEC_MERGE, stash_rev)
scm.set_ref(EXEC_BASELINE, stash_entry.baseline_rev)
# Executor will be initialized with an empty git repo that
# we populate by pushing:
# EXEC_HEAD - the base commit for this experiment
# EXEC_MERGE - the unmerged changes (from our stash)
# to be reproduced
# EXEC_BASELINE - the baseline commit for this experiment
return executor_cls.from_stash_entry(
exp.repo, stash_rev, stash_entry, **kwargs
)
def get_infofile_path(self, name: str) -> str:
return os.path.join(
self.pid_dir,
name,
f"{name}{BaseExecutor.INFOFILE_EXT}",
)
@staticmethod
@scm_locked
def collect_executor(
exp: "Experiments",
executor: BaseExecutor,
exec_result: ExecutorResult,
) -> Dict[str, str]:
results = {}
def on_diverged(ref: str, checkpoint: bool):
ref_info = ExpRefInfo.from_ref(ref)
if checkpoint:
raise CheckpointExistsError(ref_info.name)
raise ExperimentExistsError(ref_info.name)
for ref in executor.fetch_exps(
exp.scm,
force=exec_result.force,
on_diverged=on_diverged,
):
exp_rev = exp.scm.get_ref(ref)
if exp_rev:
assert exec_result.exp_hash
logger.debug("Collected experiment '%s'.", exp_rev[:7])
results[exp_rev] = exec_result.exp_hash
if exec_result.ref_info is not None:
executor.collect_cache(exp.repo, exec_result.ref_info)
return results
def match_queue_entry_by_name(
self,
exp_names: Collection[str],
*entries: Iterable[Union[QueueEntry, QueueDoneResult]],
) -> Dict[str, Optional[QueueEntry]]:
from funcy import concat
entry_name_dict: Dict[str, QueueEntry] = {}
entry_rev_dict: Dict[str, QueueEntry] = {}
for entry in concat(*entries):
if isinstance(entry, QueueDoneResult):
queue_entry: QueueEntry = entry.entry
if (
entry.result is not None
and entry.result.ref_info is not None
):
name: Optional[str] = entry.result.ref_info.name
else:
name = queue_entry.name
else:
queue_entry = entry
name = queue_entry.name
if name:
entry_name_dict[name] = queue_entry
entry_rev_dict[queue_entry.stash_rev] = queue_entry
result: Dict[str, Optional[QueueEntry]] = {}
for exp_name in exp_names:
result[exp_name] = None
if exp_name in entry_name_dict:
result[exp_name] = entry_name_dict[exp_name]
continue
if self.scm.is_sha(exp_name):
for rev in entry_rev_dict:
if rev.startswith(exp_name.lower()):
result[exp_name] = entry_rev_dict[rev]
break
return result
def stash_failed(self, entry: QueueEntry) -> None:
"""Add an entry to the failed exp stash.
Arguments:
entry: Failed queue entry to add. ``entry.stash_rev`` must be a
valid Git stash commit.
"""
if self.failed_stash is not None:
assert entry.head_rev
logger.debug("Stashing failed exp '%s'", entry.stash_rev[:7])
msg = self.failed_stash.format_message(
entry.head_rev,
baseline_rev=entry.baseline_rev,
name=entry.name,
branch=entry.branch,
)
self.scm.set_ref(
self.failed_stash.ref,
entry.stash_rev,
message=f"commit: {msg}",
)
+370
View File
@@ -0,0 +1,370 @@
import hashlib
import locale
import logging
import os
from typing import (
TYPE_CHECKING,
Collection,
Dict,
Generator,
List,
Mapping,
NamedTuple,
Optional,
Set,
)
from celery.result import AsyncResult
from funcy import cached_property
from kombu.message import Message
from dvc.daemon import daemonize
from dvc.exceptions import DvcException
from dvc.ui import ui
from ..exceptions import UnresolvedQueueExpNamesError
from ..executor.base import EXEC_TMP_DIR, ExecutorInfo, ExecutorResult
from .base import BaseStashQueue, QueueDoneResult, QueueEntry, QueueGetResult
from .tasks import run_exp
if TYPE_CHECKING:
from dvc_task.app import FSApp
from dvc_task.proc.manager import ProcessManager
from dvc_task.worker import TemporaryWorker
logger = logging.getLogger(__name__)
class _MessageEntry(NamedTuple):
msg: Message
entry: QueueEntry
class _TaskEntry(NamedTuple):
async_result: AsyncResult
entry: QueueEntry
class LocalCeleryQueue(BaseStashQueue):
"""DVC experiment queue.
Maps queued experiments to (Git) stash reflog entries.
"""
CELERY_DIR = "celery"
_shutdown_task_ids: Set[str] = set()
@cached_property
def wdir(self) -> str:
return os.path.join(self.repo.tmp_dir, EXEC_TMP_DIR, self.CELERY_DIR)
@cached_property
def celery(self) -> "FSApp":
from kombu.transport.filesystem import Channel # type: ignore
# related to https://github.com/iterative/dvc-task/issues/61
Channel.QoS.restore_at_shutdown = False
from dvc_task.app import FSApp
app = FSApp(
"dvc-exp-local",
wdir=self.wdir,
mkdir=True,
include=[
"dvc.repo.experiments.queue.tasks",
"dvc_task.proc.tasks",
],
)
app.conf.update({"task_acks_late": True})
return app
@cached_property
def proc(self) -> "ProcessManager":
from dvc_task.proc.manager import ProcessManager
return ProcessManager(self.pid_dir)
@cached_property
def worker(self) -> "TemporaryWorker":
from dvc_task.worker import TemporaryWorker
# NOTE: Use thread pool with concurrency 1 and disabled prefetch.
# Worker scaling should be handled by running additional workers,
# rather than increasing pool concurrency.
#
# We use "threads" over "solo" (inline single-threaded) execution so
# that we still have access to the control/broadcast API (which
# requires a separate message handling thread in the worker).
#
# Disabled prefetch ensures that each worker will can only schedule and
# execute up to one experiment at a time (and a worker cannot prefetch
# additional experiments from the queue).
return TemporaryWorker(
self.celery,
pool="threads",
concurrency=1,
prefetch_multiplier=1,
without_heartbeat=True,
without_mingle=True,
without_gossip=True,
timeout=10,
)
def spawn_worker(self, num: int = 1):
"""spawn one single worker to process to queued tasks.
Argument:
num: serial number of the worker.
"""
from dvc_task.proc.process import ManagedProcess
logger.debug("Spawning exp queue worker")
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]
name = f"dvc-exp-worker-{num}"
logger.debug(f"start a new worker: {name}, node: {node_name}")
if os.name == "nt":
daemonize(cmd)
else:
ManagedProcess.spawn(["dvc"] + cmd, wdir=self.wdir, name=name)
def start_workers(self, count: int) -> int:
"""start some workers to process the queued tasks.
Argument:
count: worker number to be started.
Returns:
newly spawned worker number.
"""
logger.debug(f"Spawning {count} exp queue workers")
active_worker: Dict = self.worker_status()
started = 0
for num in range(1, 1 + count):
wdir_hash = hashlib.sha256(self.wdir.encode("utf-8")).hexdigest()[
:6
]
node_name = f"dvc-exp-{wdir_hash}-{num}@localhost"
if node_name in active_worker:
logger.debug(f"Exp queue worker {node_name} already exist")
continue
self.spawn_worker(num)
started += 1
return started
def put(self, *args, **kwargs) -> QueueEntry:
"""Stash an experiment and add it to the queue."""
entry = self._stash_exp(*args, **kwargs)
self.celery.signature(run_exp.s(entry.asdict())).delay()
return entry
# NOTE: Queue consumption should not be done directly. Celery worker(s)
# will automatically consume available experiments.
def get(self) -> QueueGetResult:
raise NotImplementedError
def iter_queued(self) -> Generator[QueueEntry, None, None]:
for _, entry in self._iter_queued():
yield entry
def _iter_queued(self) -> Generator[_MessageEntry, None, None]:
for msg in self.celery.iter_queued():
if msg.headers.get("task") != run_exp.name:
continue
args, kwargs, _embed = msg.decode()
entry_dict = kwargs.get("entry_dict", args[0])
yield _MessageEntry(msg, QueueEntry.from_dict(entry_dict))
def _iter_processed(self) -> Generator[_MessageEntry, None, None]:
for msg in self.celery.iter_processed():
if msg.headers.get("task") != run_exp.name:
continue
args, kwargs, _embed = msg.decode()
entry_dict = kwargs.get("entry_dict", args[0])
yield _MessageEntry(msg, QueueEntry.from_dict(entry_dict))
def _iter_active_tasks(self) -> Generator[_TaskEntry, None, None]:
for msg, entry in self._iter_processed():
task_id = msg.headers["id"]
result: AsyncResult = AsyncResult(task_id)
if not result.ready():
yield _TaskEntry(result, entry)
def _iter_done_tasks(self) -> Generator[_TaskEntry, None, None]:
for msg, entry in self._iter_processed():
task_id = msg.headers["id"]
result: AsyncResult = AsyncResult(task_id)
if result.ready():
yield _TaskEntry(result, entry)
def iter_active(self) -> Generator[QueueEntry, None, None]:
for _, entry in self._iter_active_tasks():
yield entry
def iter_done(self) -> Generator[QueueDoneResult, None, None]:
for result, entry in self._iter_done_tasks():
try:
exp_result = self.get_result(entry)
except FileNotFoundError:
if result.status == "SUCCESS":
raise DvcException(
f"Invalid experiment '{entry.stash_rev[:7]}'."
)
elif result.status == "FAILURE":
exp_result = None
yield QueueDoneResult(entry, exp_result)
def iter_success(self) -> Generator[QueueDoneResult, None, None]:
for queue_entry, exp_result in self.iter_done():
if exp_result and exp_result.exp_hash and exp_result.ref_info:
yield QueueDoneResult(queue_entry, exp_result)
def iter_failed(self) -> Generator[QueueDoneResult, None, None]:
for queue_entry, exp_result in self.iter_done():
if exp_result is None:
yield QueueDoneResult(queue_entry, exp_result)
def reproduce(self) -> Mapping[str, Mapping[str, str]]:
raise NotImplementedError
def get_result(
self, entry: QueueEntry, timeout: Optional[float] = None
) -> Optional[ExecutorResult]:
from celery.exceptions import TimeoutError as _CeleryTimeout
def _load_info(rev: str) -> ExecutorInfo:
infofile = self.get_infofile_path(rev)
return ExecutorInfo.load_json(infofile)
def _load_collected(rev: str) -> Optional[ExecutorResult]:
executor_info = _load_info(rev)
if executor_info.collected:
return executor_info.result
raise FileNotFoundError
try:
return _load_collected(entry.stash_rev)
except FileNotFoundError:
# Infofile will not be created until execution begins
pass
for queue_entry in self.iter_queued():
if entry.stash_rev == queue_entry.stash_rev:
raise DvcException("Experiment has not been started.")
for result, active_entry in self._iter_active_tasks():
if entry.stash_rev == active_entry.stash_rev:
logger.debug(
"Waiting for exp task '%s' to complete", result.id
)
try:
result.get(timeout=timeout)
except _CeleryTimeout as exc:
raise DvcException(
"Timed out waiting for exp to finish."
) from exc
executor_info = _load_info(entry.stash_rev)
return executor_info.result
# NOTE: It's possible for an exp to complete while iterating through
# other queued and active tasks, in which case the exp will get moved
# out of the active task list, and needs to be loaded here.
return _load_collected(entry.stash_rev)
def kill(self, revs: Collection[str]) -> None:
to_kill: Set[QueueEntry] = set()
name_dict: Dict[
str, Optional[QueueEntry]
] = self.match_queue_entry_by_name(set(revs), self.iter_active())
missing_rev: List[str] = []
for rev, queue_entry in name_dict.items():
if queue_entry is None:
missing_rev.append(rev)
else:
to_kill.add(queue_entry)
if missing_rev:
raise UnresolvedQueueExpNamesError(missing_rev)
for queue_entry in to_kill:
self.proc.kill(queue_entry.stash_rev)
def shutdown(self, kill: bool = False):
self.celery.control.shutdown()
if kill:
for _, task_entry in self._iter_active_tasks():
try:
self.proc.kill(task_entry.stash_rev)
except ProcessLookupError:
continue
def follow(
self,
entry: QueueEntry,
encoding: Optional[str] = None,
):
for line in self.proc.follow(entry.stash_rev, encoding):
ui.write(line, end="")
def logs(
self,
rev: str,
encoding: Optional[str] = None,
follow: bool = False,
):
queue_entry: Optional[QueueEntry] = self.match_queue_entry_by_name(
{rev}, self.iter_active(), self.iter_done()
).get(rev)
if queue_entry is None:
if rev in self.match_queue_entry_by_name(
{rev}, self.iter_queued()
):
raise DvcException(
f"Experiment '{rev}' is in queue but has not been started"
)
raise UnresolvedQueueExpNamesError([rev])
if follow:
ui.write(
f"Following logs for experiment '{rev}'. Use Ctrl+C to stop "
"following logs (experiment execution will continue).\n"
)
try:
self.follow(queue_entry)
except KeyboardInterrupt:
pass
return
try:
proc_info = self.proc[queue_entry.stash_rev]
except KeyError:
raise DvcException(f"No output logs found for experiment '{rev}'")
with open(
proc_info.stdout,
encoding=encoding or locale.getpreferredencoding(),
) as fobj:
ui.write(fobj.read())
def worker_status(self) -> Dict:
"""Return the current active celery worker"""
status = self.celery.control.inspect().active() or {}
logger.debug(f"Worker status: {status}")
return status
def clear(self, *args, **kwargs):
from .remove import celery_clear
return celery_clear(self, *args, **kwargs)
def remove(self, *args, **kwargs):
from .remove import celery_remove
return celery_remove(self, *args, **kwargs)
+148
View File
@@ -0,0 +1,148 @@
from typing import TYPE_CHECKING, Collection, Dict, Iterable, List, Set, Union
from dvc.repo.experiments.exceptions import UnresolvedExpNamesError
from dvc.repo.experiments.queue.base import QueueDoneResult
if TYPE_CHECKING:
from dvc.repo.experiments.queue.base import QueueEntry
from dvc.repo.experiments.queue.celery import LocalCeleryQueue
from dvc.repo.experiments.stash import ExpStashEntry
def remove_tasks(
celery_queue: "LocalCeleryQueue",
queue_entries: Iterable["QueueEntry"],
):
"""Remove tasks from task queue.
Arguments:
queue_entries: An iterable list of task to remove
"""
from celery.result import AsyncResult
stash_revs: Dict[str, "ExpStashEntry"] = {}
failed_stash_revs: List["ExpStashEntry"] = []
done_entry_set: Set["QueueEntry"] = set()
stash_rev_all = celery_queue.stash.stash_revs
failed_rev_all = celery_queue.failed_stash.stash_revs
for entry in queue_entries:
if entry.stash_rev in stash_rev_all:
stash_revs[entry.stash_rev] = stash_rev_all[entry.stash_rev]
else:
done_entry_set.add(entry)
if entry.stash_rev in failed_rev_all:
failed_stash_revs.append(failed_rev_all[entry.stash_rev])
try:
for (
msg,
queue_entry,
) in celery_queue._iter_queued(): # pylint: disable=protected-access
if queue_entry.stash_rev in stash_revs:
celery_queue.celery.reject(msg.delivery_tag)
finally:
celery_queue.stash.remove_revs(list(stash_revs.values()))
try:
for (
msg,
queue_entry,
) in (
celery_queue._iter_processed() # pylint: disable=protected-access
):
if queue_entry not in done_entry_set:
continue
task_id = msg.headers["id"]
result: AsyncResult = AsyncResult(task_id)
if result is not None:
result.forget()
celery_queue.celery.purge(msg.delivery_tag)
finally:
celery_queue.failed_stash.remove_revs(failed_stash_revs)
def _get_names(entries: Iterable[Union["QueueEntry", "QueueDoneResult"]]):
names: List[str] = []
for entry in entries:
if isinstance(entry, QueueDoneResult):
if entry.result and entry.result.ref_info:
names.append(entry.result.ref_info.name)
continue
entry = entry.entry
name = entry.name
name = name or entry.stash_rev[:7]
names.append(name)
return names
def celery_clear(
self: "LocalCeleryQueue",
queued: bool = False,
failed: bool = False,
success: bool = False,
) -> List[str]:
"""Remove entries from the queue.
Arguments:
queued: Remove all queued tasks.
failed: Remove all failed tasks.
success: Remove all success tasks.
Returns:
Revisions which were removed.
"""
removed: List[str] = []
entry_list: List["QueueEntry"] = []
if queued:
queue_entries: List["QueueEntry"] = list(self.iter_queued())
entry_list.extend(queue_entries)
removed.extend(_get_names(queue_entries))
if failed:
failed_tasks: List["QueueDoneResult"] = list(self.iter_failed())
entry_list.extend([result.entry for result in failed_tasks])
removed.extend(_get_names(failed_tasks))
if success:
success_tasks: List["QueueDoneResult"] = list(self.iter_success())
entry_list.extend([result.entry for result in success_tasks])
removed.extend(_get_names(success_tasks))
remove_tasks(self, entry_list)
return removed
def celery_remove(
self: "LocalCeleryQueue",
revs: Collection[str],
) -> List[str]:
"""Remove the specified entries from the queue.
Arguments:
revs: Stash revisions or queued exp names to be removed.
Returns:
Revisions (or names) which were removed.
"""
match_results = self.match_queue_entry_by_name(
revs, self.iter_queued(), self.iter_done()
)
remained: List[str] = []
removed: List[str] = []
entry_to_remove: List["QueueEntry"] = []
for name, entry in match_results.items():
if entry:
entry_to_remove.append(entry)
removed.append(name)
else:
remained.append(name)
if remained:
raise UnresolvedExpNamesError(remained)
if entry_to_remove:
remove_tasks(self, entry_to_remove)
return removed
+125
View File
@@ -0,0 +1,125 @@
from typing import Any, Dict
from celery import shared_task
from celery.utils.log import get_task_logger
from dvc.utils.fs import remove
from ..executor.base import ExecutorInfo
from ..executor.local import TempDirExecutor
from .base import BaseStashQueue, QueueEntry
logger = get_task_logger(__name__)
@shared_task
def setup_exp(entry_dict: Dict[str, Any]) -> str:
"""Setup an experiment.
Arguments:
entry_dict: Serialized QueueEntry for this experiment.
Returns:
Root executor (temp) directory for this experiment.
"""
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.setup_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.root_dir
@shared_task
def collect_exp(
proc_dict: Dict[str, Any], # pylint: disable=unused-argument
entry_dict: Dict[str, Any],
) -> str:
"""Collect results for an experiment.
Arguments:
proc_dict: Serialized ProcessInfo for experiment executor process.
entry_dict: Serialized QueueEntry for this experiment.
Returns:
Directory to be cleaned up after this experiment.
"""
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")
return executor.root_dir
@shared_task
def cleanup_exp( # pylint: disable=unused-argument
tmp_dir: str, entry_dict: Dict[str, Any]
) -> None:
"""Cleanup after an experiment.
Arguments:
tmp_dir: Temp directory to be removed.
entry_dict: Serialized QueueEntry for this experiment.
"""
remove(tmp_dir)
def _set_collected(infofile: str):
try:
executor_info = ExecutorInfo.load_json(infofile)
executor_info.collected = True
executor_info.dump_json(infofile)
except FileNotFoundError:
pass
@shared_task
def run_exp(entry_dict: Dict[str, Any]) -> None:
"""Run a full experiment.
Experiment subtasks are executed inline as one atomic operation.
Arguments:
entry_dict: Serialized QueueEntry for this experiment.
"""
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)
root_dir = setup_exp.s(entry_dict)()
try:
cmd = ["dvc", "exp", "exec-run", "--infofile", infofile]
proc_dict = queue.proc.run_signature(cmd, name=entry.stash_rev)()
collect_exp.s(proc_dict, entry_dict)()
finally:
cleanup_exp.s(root_dir, entry_dict)()
_set_collected(infofile)
+98
View File
@@ -0,0 +1,98 @@
import logging
import os
from typing import TYPE_CHECKING, Dict, Generator, Optional
from funcy import cached_property, first
from ..exceptions import ExpQueueEmptyError
from ..executor.base import (
EXEC_PID_DIR,
EXEC_TMP_DIR,
BaseExecutor,
ExecutorInfo,
ExecutorResult,
)
from ..executor.local import TempDirExecutor
from .base import BaseStashQueue, QueueEntry, QueueGetResult
from .workspace import WorkspaceQueue
if TYPE_CHECKING:
from dvc.repo.experiments import Experiments
from dvc_task.proc.manager import ProcessManager
logger = logging.getLogger(__name__)
_STANDALONE_TMP_DIR = os.path.join(EXEC_TMP_DIR, "standalone")
class TempDirQueue(WorkspaceQueue):
"""Standalone/tempdir exp queue implementation."""
_EXEC_NAME: Optional[str] = None
@cached_property
def _standalone_tmp_dir(self) -> str:
return os.path.join(self.repo.tmp_dir, _STANDALONE_TMP_DIR)
@cached_property
def pid_dir(self) -> str:
return os.path.join(self._standalone_tmp_dir, EXEC_PID_DIR)
@cached_property
def proc(self) -> "ProcessManager":
from dvc_task.proc.manager import ProcessManager
return ProcessManager(self.pid_dir)
def get(self) -> QueueGetResult:
revs = self.stash.stash_revs
if not revs:
raise ExpQueueEmptyError("No stashed standalone experiments.")
stash_rev, stash_entry = first(revs.items())
entry = QueueEntry(
self.repo.root_dir,
self.scm.root_dir,
self.ref,
stash_rev,
stash_entry.baseline_rev,
stash_entry.branch,
stash_entry.name,
stash_entry.head_rev,
)
executor = self.setup_executor(
self.repo.experiments,
entry,
TempDirExecutor,
wdir=self._standalone_tmp_dir,
)
return QueueGetResult(entry, executor)
def iter_active(self) -> Generator[QueueEntry, None, None]:
# NOTE: Yielded queue entries are not complete for performance reasons.
# Retrieving exec ref information is unavailable without doing a
# git-fetch, and is unneeded in the common use cases for iter_active.
for stash_rev in self.proc:
infofile = self.get_infofile_path(stash_rev)
executor_info = ExecutorInfo.load_json(infofile)
if not executor_info.collected and os.path.exists(
executor_info.root_dir
):
yield QueueEntry(
self.repo.root_dir,
self.scm.root_dir,
self.ref,
stash_rev,
executor_info.baseline_rev,
None, # branch unavailable without doing a git-fetch
executor_info.name,
None,
)
@staticmethod
def collect_executor(
exp: "Experiments",
executor: BaseExecutor,
exec_result: ExecutorResult,
) -> Dict[str, str]:
return BaseStashQueue.collect_executor(exp, executor, exec_result)
+155
View File
@@ -0,0 +1,155 @@
import logging
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Collection, Dict, Generator, Optional
from funcy import first
from dvc.exceptions import DvcException
from dvc.utils.fs import remove
from ..exceptions import ExpQueueEmptyError
from ..executor.base import BaseExecutor, ExecutorResult
from ..executor.local import WorkspaceExecutor
from ..refs import EXEC_BRANCH
from .base import BaseStashQueue, QueueDoneResult, QueueEntry, QueueGetResult
if TYPE_CHECKING:
from dvc.repo.experiments import Experiments
logger = logging.getLogger(__name__)
class WorkspaceQueue(BaseStashQueue):
_EXEC_NAME: Optional[str] = "workspace"
def put(self, *args, **kwargs) -> QueueEntry:
return self._stash_exp(*args, **kwargs)
def get(self) -> QueueGetResult:
revs = self.stash.stash_revs
if not revs:
raise ExpQueueEmptyError("No experiments in the queue.")
stash_rev, stash_entry = first(revs.items())
entry = QueueEntry(
self.repo.root_dir,
self.scm.root_dir,
self.ref,
stash_rev,
stash_entry.baseline_rev,
stash_entry.branch,
stash_entry.name,
stash_entry.head_rev,
)
executor = self.setup_executor(self.repo.experiments, entry)
return QueueGetResult(entry, executor)
def iter_queued(self) -> Generator[QueueEntry, None, None]:
for rev, entry in self.stash.stash_revs:
yield QueueEntry(
self.repo.root_dir,
self.scm.root_dir,
self.ref,
rev,
entry.baseline_rev,
entry.branch,
entry.name,
entry.head_rev,
)
def iter_active(self) -> Generator[QueueEntry, None, None]:
# Workspace run state is reflected in the workspace itself and does not
# need to be handled via the queue
raise NotImplementedError
def iter_done(self) -> Generator[QueueDoneResult, None, None]:
raise NotImplementedError
def iter_failed(self) -> Generator[QueueDoneResult, None, None]:
raise NotImplementedError
def iter_success(self) -> Generator[QueueDoneResult, None, None]:
raise NotImplementedError
def reproduce(self) -> Dict[str, Dict[str, str]]:
results: Dict[str, Dict[str, str]] = defaultdict(dict)
try:
while True:
entry, executor = self.get()
results.update(self._reproduce_entry(entry, executor))
except ExpQueueEmptyError:
pass
return results
def _reproduce_entry(
self, entry: QueueEntry, executor: BaseExecutor
) -> Dict[str, Dict[str, str]]:
from dvc.stage.monitor import CheckpointKilledError
results: Dict[str, Dict[str, str]] = defaultdict(dict)
exec_name = self._EXEC_NAME or entry.stash_rev
infofile = self.get_infofile_path(exec_name)
try:
rev = entry.stash_rev
exec_result = executor.reproduce(
info=executor.info,
rev=rev,
infofile=infofile,
log_level=logger.getEffectiveLevel(),
log_errors=not isinstance(executor, WorkspaceExecutor),
)
if not exec_result.exp_hash:
raise DvcException(
f"Failed to reproduce experiment '{rev[:7]}'"
)
if exec_result.ref_info:
results[rev].update(
self.collect_executor(
self.repo.experiments, executor, exec_result
)
)
except CheckpointKilledError:
# Checkpoint errors have already been logged
return {}
except DvcException:
raise
except Exception as exc:
raise DvcException(
f"Failed to reproduce experiment '{rev[:7]}'"
) from exc
finally:
if self._EXEC_NAME == exec_name:
remove(os.path.join(self.pid_dir, exec_name))
executor.cleanup()
return results
@staticmethod
def collect_executor( # pylint: disable=unused-argument
exp: "Experiments",
executor: BaseExecutor,
exec_result: ExecutorResult,
) -> Dict[str, str]:
results: Dict[str, str] = {}
exp_rev = exp.scm.get_ref(EXEC_BRANCH)
if exp_rev:
assert exec_result.exp_hash
logger.debug("Collected experiment '%s'.", exp_rev[:7])
results[exp_rev] = exec_result.exp_hash
return results
def get_result(self, entry: QueueEntry) -> Optional[ExecutorResult]:
raise NotImplementedError
def kill(self, revs: Collection[str]) -> None:
raise NotImplementedError
def shutdown(self, kill: bool = False):
raise NotImplementedError
def logs(
self,
rev: str,
encoding: Optional[str] = None,
follow: bool = False,
):
raise NotImplementedError
+67
View File
@@ -0,0 +1,67 @@
from typing import Optional
from .exceptions import InvalidExpRefError
# Experiment refs are stored according baseline git SHA:
# refs/exps/01/234abcd.../<exp_name>
EXPS_NAMESPACE = "refs/exps"
EXPS_STASH = f"{EXPS_NAMESPACE}/stash"
WORKSPACE_STASH = EXPS_STASH
CELERY_STASH = f"{EXPS_NAMESPACE}/celery/stash"
CELERY_FAILED_STASH = f"{EXPS_NAMESPACE}/celery/failed"
EXEC_NAMESPACE = f"{EXPS_NAMESPACE}/exec"
EXEC_APPLY = f"{EXEC_NAMESPACE}/EXEC_APPLY"
EXEC_CHECKPOINT = f"{EXEC_NAMESPACE}/EXEC_CHECKPOINT"
EXEC_BRANCH = f"{EXEC_NAMESPACE}/EXEC_BRANCH"
EXEC_BASELINE = f"{EXEC_NAMESPACE}/EXEC_BASELINE"
EXEC_HEAD = f"{EXEC_NAMESPACE}/EXEC_HEAD"
EXEC_MERGE = f"{EXEC_NAMESPACE}/EXEC_MERGE"
STASHES = {WORKSPACE_STASH, CELERY_STASH}
class ExpRefInfo:
namespace = EXPS_NAMESPACE
def __init__(
self, baseline_sha: Optional[str] = None, name: Optional[str] = None
):
self.baseline_sha = baseline_sha
self.name: str = name if name else ""
def __str__(self):
return "/".join(self.parts)
def __repr__(self):
baseline = f"'{self.baseline_sha}'" if self.baseline_sha else "None"
name = f"'{self.name}'" if self.name else "None"
return f"ExpRefInfo(baseline_sha={baseline}, name={name})"
@property
def parts(self):
return (
(self.namespace,)
+ (
(self.baseline_sha[:2], self.baseline_sha[2:])
if self.baseline_sha
else ()
)
+ ((self.name,) if self.name else ())
)
@classmethod
def from_ref(cls, ref: str):
try:
parts = ref.split("/")
if (
len(parts) < 2
or len(parts) == 3
or len(parts) > 5
or "/".join(parts[:2]) != EXPS_NAMESPACE
):
raise InvalidExpRefError(ref)
except ValueError:
raise InvalidExpRefError(ref)
baseline_sha = parts[2] + parts[3] if len(parts) >= 4 else None
name = parts[4] if len(parts) == 5 else None
return cls(baseline_sha, name)
+91 -111
View File
@@ -1,29 +1,79 @@
import logging
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Set, Union
from typing import (
TYPE_CHECKING,
Dict,
Iterable,
List,
NamedTuple,
Optional,
Union,
)
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm import iter_revs
from .exceptions import UnresolvedExpNamesError
from .utils import (
exp_refs,
exp_refs_by_baseline,
push_refspec,
remove_exp_refs,
resolve_name,
)
from .refs import ExpRefInfo
from .utils import exp_refs, exp_refs_by_baseline, push_refspec
if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.repo.experiments.queue.celery import LocalCeleryQueue
from dvc.scm import Git
from .base import ExpRefInfo
from .queue.base import QueueEntry
logger = logging.getLogger(__name__)
class ExpRefAndQueueEntry(NamedTuple):
exp_ref_list: List["ExpRefInfo"]
queue_entry_list: List["QueueEntry"]
removed: List["str"]
def _get_ref_and_entry_by_names(
exp_names: Union[str, List[str]],
scm: "Git",
celery_queue: "LocalCeleryQueue",
git_remote: Optional[str],
) -> ExpRefAndQueueEntry:
from .utils import resolve_name
exp_ref_list: List["ExpRefInfo"] = []
queue_entry_list: List["QueueEntry"] = []
removed: List[str] = []
if isinstance(exp_names, str):
exp_names = [exp_names]
exp_ref_match: Dict[str, Optional["ExpRefInfo"]] = resolve_name(
scm, exp_names, git_remote
)
if not git_remote:
queue_entry_match: Dict[
str, Optional["QueueEntry"]
] = celery_queue.match_queue_entry_by_name(
exp_names, celery_queue.iter_queued(), celery_queue.iter_done()
)
remained = []
for exp_name in exp_names:
exp_ref = exp_ref_match[exp_name]
queue_entry = None if git_remote else queue_entry_match[exp_name]
if exp_ref or queue_entry:
if exp_ref:
exp_ref_list.append(exp_ref)
if queue_entry:
queue_entry_list.append(queue_entry)
removed.append(exp_name)
else:
remained.append(exp_name)
if remained:
raise UnresolvedExpNamesError(remained)
return ExpRefAndQueueEntry(exp_ref_list, queue_entry_list, removed)
@locked
@scm_context
def remove(
@@ -38,130 +88,66 @@ def remove(
removed: List[str] = []
if not any([exp_names, queue, all_commits, rev]):
return removed
celery_queue: "LocalCeleryQueue" = repo.experiments.celery_queue
if queue:
removed.extend(_clear_stash(repo))
removed.extend(celery_queue.clear(queued=True))
if all_commits:
removed.extend(_clear_all_commits(repo, git_remote))
removed.extend(
_remove_commited_exps(
repo.scm, list(exp_refs(repo.scm, git_remote)), git_remote
)
)
return removed
commit_ref_dict: Dict["ExpRefInfo", str] = {}
queued_ref_dict: Dict[int, str] = {}
exp_ref_list: List["ExpRefInfo"] = []
queue_entry_list: List["QueueEntry"] = []
if exp_names:
_resolve_exp_by_name(
repo, exp_names, commit_ref_dict, queued_ref_dict, git_remote
result = _get_ref_and_entry_by_names(
exp_names, repo.scm, celery_queue, git_remote
)
removed.extend(result.removed)
exp_ref_list.extend(result.exp_ref_list)
queue_entry_list.extend(result.queue_entry_list)
elif rev:
exp_ref_dict = _resolve_exp_by_baseline(repo, rev, num, git_remote)
removed.extend(exp_ref_dict.keys())
exp_ref_list.extend(exp_ref_dict.values())
if rev:
_resolve_exp_by_baseline(repo, rev, num, commit_ref_dict, git_remote)
if exp_ref_list:
_remove_commited_exps(repo.scm, exp_ref_list, git_remote)
if commit_ref_dict:
removed.extend(
_remove_commited_exps(repo.scm, commit_ref_dict, git_remote)
)
if queue_entry_list:
from .queue.remove import remove_tasks
if queued_ref_dict:
removed.extend(_remove_queued_exps(repo, queued_ref_dict))
remove_tasks(celery_queue, queue_entry_list)
return removed
def _resolve_exp_by_name(
repo: "Repo",
exp_names: Union[str, List[str]],
commit_ref_dict: Dict["ExpRefInfo", str],
queued_ref_dict: Dict[int, str],
git_remote: Optional[str],
):
remained = set()
if isinstance(exp_names, str):
exp_names = [exp_names]
exp_ref_dict = resolve_name(repo.scm, exp_names, git_remote)
for exp_name, exp_ref in exp_ref_dict.items():
if exp_ref is None:
remained.add(exp_name)
else:
commit_ref_dict[exp_ref] = exp_name
if not git_remote:
stash_index_dict = _get_queued_index_by_names(repo, remained)
for exp_name, stash_index in stash_index_dict.items():
if stash_index is not None:
queued_ref_dict[stash_index] = exp_name
remained.remove(exp_name)
if remained:
raise UnresolvedExpNamesError(remained)
def _resolve_exp_by_baseline(
repo,
rev: str,
num: int,
commit_ref_dict: Dict["ExpRefInfo", str],
git_remote: Optional[str] = None,
):
) -> Dict[str, "ExpRefInfo"]:
commit_ref_dict: Dict[str, "ExpRefInfo"] = {}
rev_dict = iter_revs(repo.scm, [rev], num)
rev_set = set(rev_dict.keys())
ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set, git_remote)
for _, ref_info_list in ref_info_dict.items():
for ref_info in ref_info_list:
if ref_info not in commit_ref_dict:
commit_ref_dict[ref_info] = ref_info.name
def _clear_stash(repo: "Repo") -> List[str]:
removed_name_list = []
for entry in repo.experiments.stash.list():
new_sha = entry.new_sha.decode("utf8")
name = repo.experiments.get_exact_name(new_sha)
if not name:
name = new_sha[:7]
removed_name_list.append(name)
repo.experiments.stash.clear()
return removed_name_list
def _clear_all_commits(repo, git_remote) -> List:
ref_infos = {
ref_info: ref_info.name for ref_info in exp_refs(repo.scm, git_remote)
}
return _remove_commited_exps(repo.scm, ref_infos, git_remote)
def _get_queued_index_by_names(
repo,
exp_name_set: Set[str],
) -> Mapping[str, Optional[int]]:
from scmrepo.exceptions import RevError as InternalRevError
result = {}
stash_revs = repo.experiments.stash_revs
for _, entry in stash_revs.items():
if entry.name in exp_name_set:
result[entry.name] = entry.stash_index
for exp_name in exp_name_set:
if exp_name in result:
continue
try:
rev = repo.scm.resolve_rev(exp_name)
if rev in stash_revs:
result[exp_name] = stash_revs.get(rev).stash_index
except InternalRevError:
result[exp_name] = None
return result
commit_ref_dict[ref_info.name] = ref_info
return commit_ref_dict
def _remove_commited_exps(
scm: "Git", exp_ref_dict: Mapping["ExpRefInfo", str], remote: Optional[str]
scm: "Git", exp_refs_list: Iterable[ExpRefInfo], remote: Optional[str]
) -> List[str]:
if remote:
from dvc.scm import TqdmGit
for ref_info in exp_ref_dict:
for ref_info in exp_refs_list:
with TqdmGit(desc="Pushing git refs") as pbar:
push_refspec(
scm,
@@ -171,13 +157,7 @@ def _remove_commited_exps(
progress=pbar.update_git,
)
else:
remove_exp_refs(scm, exp_ref_dict)
return list(exp_ref_dict.values())
from .utils import remove_exp_refs
def _remove_queued_exps(repo, indexes: Mapping[int, str]) -> List[str]:
index_list = list(indexes.keys())
index_list.sort(reverse=True)
for index in index_list:
repo.experiments.stash.drop(index)
return list(indexes.values())
remove_exp_refs(scm, exp_refs_list)
return [exp_ref.name for exp_ref in exp_refs_list]
+22 -5
View File
@@ -1,8 +1,9 @@
import logging
from typing import Iterable, Optional
from typing import Dict, Iterable, Optional
from dvc.repo import locked
from dvc.utils.cli_parse import loads_param_overrides
from dvc.ui import ui
from dvc.utils.cli_parse import to_path_overrides
logger = logging.getLogger(__name__)
@@ -15,8 +16,9 @@ def run(
run_all: bool = False,
jobs: int = 1,
tmp_dir: bool = False,
queue: bool = False,
**kwargs,
) -> dict:
) -> Dict[str, str]:
"""Reproduce the specified targets as an experiment.
Accepts the same additional kwargs as Repo.reproduce.
@@ -25,10 +27,25 @@ def run(
of `repro` for that experiment.
"""
if run_all:
return repo.experiments.reproduce_queued(jobs=jobs)
entries = list(repo.experiments.celery_queue.iter_queued())
return repo.experiments.reproduce_celery(entries, jobs=jobs)
if params:
params = loads_param_overrides(params)
params = to_path_overrides(params)
if queue:
if not kwargs.get("checkpoint_resume", None):
kwargs["reset"] = True
queue_entry = repo.experiments.queue_one(
repo.experiments.celery_queue,
targets=targets,
params=params,
**kwargs,
)
name = queue_entry.name or queue_entry.stash_rev[:7]
ui.write(f"Queued experiment '{name}' for future execution.")
return {}
return repo.experiments.reproduce_one(
targets=targets, params=params, tmp_dir=tmp_dir, **kwargs
)
+11 -3
View File
@@ -1,14 +1,16 @@
import logging
from collections import OrderedDict, defaultdict
from datetime import datetime
from itertools import chain
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
from dvc.repo.experiments.base import ExpRefInfo
from dvc.repo.metrics.show import _gather_metrics
from dvc.repo.params.show import _gather_params
from dvc.scm import iter_revs
from dvc.utils import error_handler, onerror_collect, relpath
from .refs import ExpRefInfo
if TYPE_CHECKING:
from dvc.repo import Repo
@@ -191,8 +193,14 @@ def show(
running=running,
onerror=onerror,
)
# collect queued (not yet reproduced) experiments
for stash_rev, entry in repo.experiments.stash_revs.items():
# collect standalone & celery experiments
for entry in chain(
repo.experiments.tempdir_queue.iter_active(),
repo.experiments.celery_queue.iter_active(),
repo.experiments.celery_queue.iter_queued(),
):
stash_rev = entry.stash_rev
if entry.baseline_rev in found_revs:
if stash_rev not in running or not running[stash_rev].get(
"last"
+74
View File
@@ -0,0 +1,74 @@
import re
from typing import Dict, Iterable, NamedTuple, Optional
from scmrepo.git import Stash
class ExpStashEntry(NamedTuple):
"""Experiment 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 to be checked out for this experiment.
baseline_rev: Experiment baseline commit.
branch: Optional exp (checkpoint) branch name for this experiment.
name: Optional exp name.
"""
stash_index: Optional[int]
head_rev: str
baseline_rev: str
branch: Optional[str]
name: Optional[str]
class ExpStash(Stash):
MESSAGE_FORMAT = "dvc-exp:{rev}:{baseline_rev}:{name}"
MESSAGE_RE = re.compile(
r"(?:commit: )"
r"dvc-exp:(?P<rev>[0-9a-f]+):(?P<baseline_rev>[0-9a-f]+)"
r":(?P<name>[^~^:\\?\[\]*]*)"
r"(:(?P<branch>.+))?$"
)
@property
def stash_revs(self) -> Dict[str, ExpStashEntry]:
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")] = ExpStashEntry(
i,
m.group("rev"),
m.group("baseline_rev"),
m.group("branch"),
m.group("name"),
)
return revs
@classmethod
def format_message(
cls,
rev: str,
baseline_rev: str,
name: Optional[str] = None,
branch: Optional[str] = None,
) -> str:
msg = cls.MESSAGE_FORMAT.format(
rev=rev, baseline_rev=baseline_rev, name=name if name else ""
)
branch_msg = f":{branch}" if branch else ""
return f"{msg}{branch_msg}"
def remove_revs(self, stash_revs: Iterable[ExpStashEntry]):
"""Remove the specified entries from the queue by stash revision."""
for index in sorted(
(
entry.stash_index
for entry in stash_revs
if entry.stash_index is not None
),
reverse=True,
):
self.drop(index)
+58 -12
View File
@@ -1,4 +1,5 @@
from collections import defaultdict
from functools import wraps
from typing import (
Callable,
Dict,
@@ -16,15 +17,45 @@ from scmrepo.git import Git
from dvc.exceptions import InvalidArgumentError
from dvc.repo.experiments.exceptions import AmbiguousExpRefInfo
from .base import (
from .refs import (
EXEC_APPLY,
EXEC_BASELINE,
EXEC_BRANCH,
EXEC_CHECKPOINT,
EXEC_NAMESPACE,
EXPS_NAMESPACE,
EXPS_STASH,
STASHES,
ExpRefInfo,
)
def scm_locked(f):
# Lock the experiments workspace so that we don't try to perform two
# different sequences of git operations at once
@wraps(f)
def wrapper(exp, *args, **kwargs):
from dvc.scm import map_scm_exception
with map_scm_exception(), exp.scm_lock:
return f(exp, *args, **kwargs)
return wrapper
def unlocked_repo(f):
@wraps(f)
def wrapper(exp, *args, **kwargs):
exp.repo.lock.unlock()
exp.repo._reset() # pylint: disable=protected-access
try:
ret = f(exp, *args, **kwargs)
finally:
exp.repo.lock.lock()
return ret
return wrapper
def exp_refs(
scm: "Git", url: Optional[str] = None
) -> Generator["ExpRefInfo", None, None]:
@@ -35,17 +66,15 @@ def exp_refs(
else scm.iter_refs(base=EXPS_NAMESPACE)
)
for ref in ref_gen:
if ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH:
if ref.startswith(EXEC_NAMESPACE) or ref in STASHES:
continue
yield ExpRefInfo.from_ref(ref)
def exp_refs_by_rev(
scm: "Git", rev: str
) -> Generator["ExpRefInfo", None, None]:
def exp_refs_by_rev(scm: "Git", rev: str) -> Generator[ExpRefInfo, None, None]:
"""Iterate over all experiment refs pointing to the specified revision."""
for ref in scm.get_refs_containing(rev, EXPS_NAMESPACE):
if not (ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH):
if not (ref.startswith(EXEC_NAMESPACE) or ref in STASHES):
yield ExpRefInfo.from_ref(ref)
@@ -121,6 +150,14 @@ def push_refspec(
raise GitAuthError(str(exc))
def remote_exp_refs(scm: "Git", url: str) -> Generator[ExpRefInfo, None, None]:
"""Iterate over all remote experiment refs."""
for ref in iter_remote_refs(scm, url, base=EXPS_NAMESPACE):
if ref.startswith(EXEC_NAMESPACE) or ref in STASHES:
continue
yield ExpRefInfo.from_ref(ref)
def exp_refs_by_names(
scm: "Git", names: Set[str], url: Optional[str] = None
) -> Dict[str, List[ExpRefInfo]]:
@@ -134,8 +171,19 @@ def exp_refs_by_names(
return resolve_results
def remote_exp_refs_by_baseline(
scm: "Git", url: str, rev: str
) -> Generator[ExpRefInfo, None, None]:
"""Iterate over all remote experiment refs with the specified baseline."""
ref_info = ExpRefInfo(baseline_sha=rev)
for ref in iter_remote_refs(scm, url, base=str(ref_info)):
if ref.startswith(EXEC_NAMESPACE) or ref in STASHES:
continue
yield ExpRefInfo.from_ref(ref)
def exp_commits(
scm: "Git", ref_infos: Iterable["ExpRefInfo"] = None
scm: "Git", ref_infos: Iterable[ExpRefInfo] = None
) -> Iterable[str]:
"""Iterate over all experiment commits."""
shas: Set["str"] = set()
@@ -147,9 +195,7 @@ def exp_commits(
yield from shas
def remove_exp_refs(scm: "Git", ref_infos: Iterable["ExpRefInfo"]):
from .base import EXEC_APPLY, EXEC_BRANCH, EXEC_CHECKPOINT
def remove_exp_refs(scm: "Git", ref_infos: Iterable[ExpRefInfo]):
exec_branch = scm.get_ref(EXEC_BRANCH, follow=False)
exec_apply = scm.get_ref(EXEC_APPLY)
exec_checkpoint = scm.get_ref(EXEC_CHECKPOINT)
@@ -221,7 +267,7 @@ def fetch_all_exps(scm: "Git", url: str, progress: Optional[Callable] = None):
refspecs = [
f"{ref}:{ref}"
for ref in iter_remote_refs(scm, url, base=EXPS_NAMESPACE)
if not (ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH)
if not (ref.startswith(EXEC_NAMESPACE) or ref in STASHES)
]
scm.fetch_refspecs(
url,
+34
View File
@@ -93,6 +93,18 @@ def fetch(
downloaded += d
failed += f
d, f = _fetch_partial_imports(
self,
targets,
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
recursive=recursive,
revs=revs,
)
downloaded += d
failed += f
if failed:
raise DownloadError(failed)
@@ -107,3 +119,25 @@ def _fetch(repo, obj_ids, **kwargs):
except FileTransferError as exc:
failed += exc.amount
return downloaded, failed
def _fetch_partial_imports(repo, targets, **kwargs):
from dvc.stage.exceptions import DataSourceChanged
downloaded = 0
failed = 0
for stage in repo.partial_imports(targets, **kwargs):
try:
stage.run()
except DataSourceChanged as exc:
logger.warning(f"{exc}")
failed += 1
continue
if not any(
kwargs.get(kw, None)
for kw in ("all_branches", "all_tags", "all_commits", "revs")
):
stage.dump()
downloaded += 1
return downloaded, failed
+33 -15
View File
@@ -1,45 +1,60 @@
import logging
from typing import TYPE_CHECKING, List, Optional
from dvc.exceptions import InvalidArgumentError
from . import locked
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
def _raise_error_if_all_disabled(**kwargs):
def _validate_args(**kwargs):
if not any(kwargs.values()):
raise InvalidArgumentError(
"Either of `-w|--workspace`, `-a|--all-branches`, `-T|--all-tags` "
"`--all-experiments` or `--all-commits` needs to be set."
"`--all-experiments`, `--all-commits`, `--date` or `--rev` "
"needs to be set."
)
if kwargs.get("num") and not kwargs.get("rev"):
raise InvalidArgumentError(
"`--num` can only be used alongside `--rev`"
)
@locked
def gc(
self,
all_branches=False,
cloud=False,
remote=None,
with_deps=False,
all_tags=False,
all_commits=False,
all_experiments=False,
force=False,
jobs=None,
repos=None,
workspace=False,
self: "Repo",
all_branches: bool = False,
cloud: bool = False,
remote: Optional[str] = None,
with_deps: bool = False,
all_tags: bool = False,
all_commits: bool = False,
all_experiments: bool = False,
force: bool = False,
jobs: int = None,
repos: Optional[List[str]] = None,
workspace: bool = False,
commit_date: Optional[str] = None,
rev: Optional[str] = None,
num: Optional[int] = None,
):
# require `workspace` to be true to come into effect.
# assume `workspace` to be enabled if any of `all_tags`, `all_commits`,
# `all_experiments` or `all_branches` are enabled.
_raise_error_if_all_disabled(
_validate_args(
workspace=workspace,
all_tags=all_tags,
all_commits=all_commits,
all_branches=all_branches,
all_experiments=all_experiments,
commit_date=commit_date,
rev=rev,
num=num,
)
from contextlib import ExitStack
@@ -64,9 +79,12 @@ def gc(
all_tags=all_tags,
all_commits=all_commits,
all_experiments=all_experiments,
commit_date=commit_date,
remote=remote,
force=force,
jobs=jobs,
revs=[rev] if rev else None,
num=num or 1,
).values():
used_obj_ids.update(obj_ids)
+2 -2
View File
@@ -54,8 +54,8 @@ def get(url, path, out=None, rev=None, jobs=None):
if os.path.isabs(path):
from dvc.fs.data import DataFileSystem
fs = DataFileSystem(repo=repo, workspace="local")
fs_path = path
fs = DataFileSystem(index=repo.index.data["local"])
fs_path = fs.from_os_path(path)
else:
fs = repo.dvcfs
fs_path = fs.from_os_path(path)
+4 -3
View File
@@ -17,6 +17,7 @@ def imp_url(
fname=None,
erepo=None,
frozen=True,
no_download=False,
no_exec=False,
remote=None,
to_remote=False,
@@ -31,9 +32,9 @@ def imp_url(
self, out, always_local=to_remote and not out
)
if to_remote and no_exec:
if to_remote and (no_exec or no_download):
raise InvalidArgumentError(
"--no-exec can't be combined with --to-remote"
"--no-exec/--no-download cannot be combined with --to-remote"
)
if not to_remote and remote:
@@ -80,7 +81,7 @@ def imp_url(
stage.save_deps()
stage.md5 = stage.compute_md5()
else:
stage.run(jobs=jobs)
stage.run(jobs=jobs, no_download=no_download)
stage.frozen = frozen
+49 -13
View File
@@ -25,8 +25,8 @@ if TYPE_CHECKING:
from dvc.repo.stage import StageLoad
from dvc.stage import Stage
from dvc.types import StrPath, TargetType
from dvc_data import Tree
from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.index import DataIndex
from dvc_objects.db import ObjectDB
@@ -168,33 +168,47 @@ class Index:
return build_outs_graph(self.graph, self.outs_trie)
@cached_property
def tree(self) -> "Tree":
from dvc.config import NoRemoteError
from dvc_data import Tree
def data(self) -> "Dict[str, DataIndex]":
from collections import defaultdict
tree = Tree()
from dvc.config import NoRemoteError
from dvc_data.index import DataIndex, DataIndexEntry
by_workspace: dict = defaultdict(DataIndex)
by_workspace["repo"] = DataIndex()
by_workspace["local"] = DataIndex()
for out in self.outs:
if not out.use_cache:
continue
if out.is_in_repo:
fs_key = "repo"
workspace = "repo"
key = self.repo.fs.path.relparts(
out.fs_path, self.repo.root_dir
)
else:
fs_key = out.fs.protocol
key = out.fs.path.parts(out.fs_path)
workspace = out.fs.protocol
no_drive = out.fs.path.flavour.splitdrive(out.fs_path)[1]
key = out.fs.path.parts(no_drive)[1:]
out.meta.odb = out.odb
try:
out.meta.remote = self.repo.cloud.get_remote_odb(out.remote)
remote = self.repo.cloud.get_remote_odb(out.remote)
except NoRemoteError:
out.meta.remote = None
tree.add((fs_key,) + key, out.meta, out.hash_info)
remote = None
return tree
data_index = by_workspace[workspace]
data_index[key] = DataIndexEntry(
meta=out.meta,
obj=out.obj,
hash_info=out.hash_info,
odb=out.odb,
remote=remote,
)
return dict(by_workspace)
def used_objs(
self,
@@ -232,6 +246,28 @@ class Index:
used[odb].update(objs)
return used
def partial_imports(
self,
targets: "TargetType" = None,
recursive: bool = False,
) -> List["Stage"]:
from itertools import chain
from dvc.utils.collections import ensure_list
collect_targets: Sequence[Optional[str]] = (None,)
if targets:
collect_targets = ensure_list(targets)
pairs = chain.from_iterable(
self.stage_collector.collect_granular(
target, recursive=recursive, with_deps=True
)
for target in collect_targets
)
return [stage for stage, _ in pairs if stage.is_partial_import]
# Following methods help us treat the collection as a set-like structure
# and provides faux-immutability.
# These methods do not preserve stages order.
+11 -12
View File
@@ -1,5 +1,4 @@
import os
from itertools import chain
from typing import TYPE_CHECKING, Optional
from dvc.exceptions import PathMissingError
@@ -66,15 +65,16 @@ def _ls(
infos = {}
for root, dirs, files in fs.walk(
fs_path, dvcfiles=True, dvc_only=dvc_only
fs_path, dvcfiles=True, dvc_only=dvc_only, detail=True
):
entries = chain(files, dirs) if not recursive else files
if not recursive:
files.update(dirs)
for entry in entries:
entry_fs_path = fs.path.join(root, entry)
for name, entry in files.items():
entry_fs_path = fs.path.join(root, name)
relparts = fs.path.relparts(entry_fs_path, fs_path)
name = os.path.join(*relparts)
infos[name] = fs.info(entry_fs_path)
infos[name] = entry
if not recursive:
break
@@ -85,11 +85,10 @@ def _ls(
ret = {}
for name, info in infos.items():
dvc_info = info.get("dvc_info", {})
if dvc_info.get("outs") or not dvc_only:
ret[name] = {
"isout": dvc_info.get("isout", False),
"isdir": info["type"] == "directory",
"isexec": info.get("isexec", False),
}
ret[name] = {
"isout": dvc_info.get("isout", False),
"isdir": info["type"] == "directory",
"isexec": info.get("isexec", False),
}
return ret
+8 -8
View File
@@ -12,7 +12,7 @@ from dvc.repo.live import summary_fs_path
from dvc.scm import NoSCMError
from dvc.utils import error_handler, errored_revisions, onerror_collect
from dvc.utils.collections import ensure_list
from dvc.utils.serialize import LOADERS
from dvc.utils.serialize import load_path
logger = logging.getLogger(__name__)
@@ -71,9 +71,7 @@ def _extract_metrics(metrics, path, rev):
@error_handler
def _read_metric(path, fs, rev, **kwargs):
suffix = fs.path.suffix(path).lower()
loader = LOADERS[suffix]
val = loader(path, fs=fs)
val = load_path(path, fs)
val = _extract_metrics(val, path, rev)
return val or {}
@@ -87,12 +85,14 @@ def _read_metrics(repo, metrics, rev, onerror=None):
res = {}
for metric in metrics:
rel_metric_path = os.path.join(relpath, *fs.path.parts(metric))
if not fs.isfile(metric):
continue
if fs.isfile(rel_metric_path):
metric = rel_metric_path
else:
continue
res[os.path.join(relpath, *fs.path.parts(metric))] = _read_metric(
metric, fs, rev, onerror=onerror
)
res[rel_metric_path] = _read_metric(metric, fs, rev, onerror=onerror)
return res
+7 -8
View File
@@ -22,7 +22,7 @@ from dvc.stage import PipelineStage
from dvc.ui import ui
from dvc.utils import error_handler, errored_revisions, onerror_collect
from dvc.utils.collections import ensure_list
from dvc.utils.serialize import LOADERS
from dvc.utils.serialize import load_path
if TYPE_CHECKING:
from dvc.output import Output
@@ -36,7 +36,7 @@ def _is_params(dep: "Output"):
def _collect_configs(
repo: "Repo", rev, targets=None, duplicates=False
repo: "Repo", rev, targets=None, deps=False, stages=None
) -> Tuple[List["Output"], List[str]]:
params, fs_paths = collect(
@@ -45,10 +45,10 @@ def _collect_configs(
deps=True,
output_filter=_is_params,
rev=rev,
duplicates=duplicates,
duplicates=deps or stages is not None,
)
all_fs_paths = fs_paths + [p.fs_path for p in params]
if not targets:
if not any([deps, targets, stages]):
default_params = repo.fs.path.join(
repo.root_dir, ParamsDependency.DEFAULT_PARAMS_FILE
)
@@ -56,14 +56,13 @@ def _collect_configs(
default_params
):
fs_paths.append(default_params)
return params, fs_paths
@error_handler
def _read_fs_path(fs, fs_path, **kwargs):
suffix = fs.path.suffix(fs_path).lower()
loader = LOADERS[suffix]
return loader(fs_path, fs=fs)
return load_path(fs_path, fs)
def _read_params(
@@ -173,7 +172,7 @@ def _gather_params(
repo, rev, targets=None, deps=False, onerror=None, stages=None
):
param_outs, params_fs_paths = _collect_configs(
repo, rev, targets=targets, duplicates=deps or stages
repo, rev, targets=targets, deps=deps, stages=stages
)
params = _read_params(
repo,
+15 -18
View File
@@ -342,16 +342,22 @@ def _matches(targets, config_file, plot_id):
return False
def _dvcfile_relpath(dvcfile):
fs = dvcfile.repo.dvcfs
def _normpath(path):
# TODO dvcfs.path.normopath normalizes to windows path on Windows
# even though other methods work as expected
import posixpath
return posixpath.normpath(path)
def _relpath(fs, path):
# TODO from_os_path changes abs to relative
# TODO we should be using `dvcfile.relpath` - in case of GitFS (plots diff)
# and invoking from some subdir `dvcfile.relpath` returns strange long
# relative paths
# ("../../../../../../dvc.yaml") - investigate
return fs.path.relpath(
fs.path.join("/", fs.from_os_path(dvcfile.path)), fs.path.getcwd()
fs.path.join("/", fs.from_os_path(path)), fs.path.getcwd()
)
@@ -363,12 +369,12 @@ def _collect_output_plots(
for plot in repo.index.plots:
plot_props = _plot_props(plot)
dvcfile = plot.stage.dvcfile
config_path = _dvcfile_relpath(dvcfile)
config_dirname = os.path.dirname(config_path)
config_path = _relpath(fs, dvcfile.path)
wdir_relpath = _relpath(fs, plot.stage.wdir)
if _matches(targets, config_path, str(plot)):
unpacked = unpack_if_dir(
fs,
fs.path.join(config_dirname, plot.def_path),
_normpath(fs.path.join(wdir_relpath, plot.def_path)),
props={**plot_props, **props},
onerror=onerror,
)
@@ -381,11 +387,6 @@ def _collect_output_plots(
def _adjust_definitions_to_cwd(fs, config_relpath, plots_definitions):
# TODO normopath normalizes to windows path on Windows
# investigate
import posixpath
result = defaultdict(dict)
config_dirname = fs.path.dirname(config_relpath)
@@ -395,18 +396,14 @@ def _adjust_definitions_to_cwd(fs, config_relpath, plots_definitions):
y_def = plot_def.get("y", None) if plot_def else None
if y_def is None or not isinstance(y_def, dict):
# plot_id is filename
new_plot_id = posixpath.normpath(
fs.path.join(config_dirname, plot_id)
)
new_plot_id = _normpath(fs.path.join(config_dirname, plot_id))
result[new_plot_id] = plot_def or {}
else:
new_plot_def = deepcopy(plot_def)
old_y = new_plot_def.pop("y")
new_y = {}
for filepath, val in old_y.items():
new_y[
posixpath.normpath(fs.path.join(config_dirname, filepath))
] = val
new_y[_normpath(fs.path.join(config_dirname, filepath))] = val
new_plot_def["y"] = new_y
result[plot_id] = new_plot_def
return dict(result)
@@ -419,7 +416,7 @@ def _collect_pipeline_files(repo, targets: List[str], props):
dvcfiles = {stage.dvcfile for stage in repo.index.stages}
for dvcfile in dvcfiles:
if isinstance(dvcfile, PipelineFile):
dvcfile_path = _dvcfile_relpath(dvcfile)
dvcfile_path = _relpath(repo.dvcfs, dvcfile.path)
dvcfile_defs = _adjust_definitions_to_cwd(
repo.fs, dvcfile_path, dvcfile.load().get("plots", {})
)
+3 -1
View File
@@ -64,7 +64,9 @@ def _get_stage_files(stage: "Stage") -> typing.Iterator[str]:
if (
not dep.use_scm_ignore
and dep.is_in_repo
and not stage.repo.dvcfs.isdvc(dep.fs_path)
and not stage.repo.dvcfs.isdvc(
stage.repo.dvcfs.from_os_path(str(dep))
)
):
yield dep.fs_path
for out in stage.outs:
+1 -1
View File
@@ -46,7 +46,7 @@ class SCMContext:
@staticmethod
def _make_git_add_cmd(paths: Union[str, Iterable[str]]) -> str:
files = " ".join(map(shlex.quote, ensure_list(paths)))
return f"\tgit add {files}".expandtabs(4)
return f"\tgit add {files}"
def add(self, paths: Union[str, Iterable[str]]) -> None:
from scmrepo.exceptions import UnsupportedIndexFormat
+13 -1
View File
@@ -10,6 +10,7 @@ def update(
rev=None,
recursive=False,
to_remote=False,
no_download=False,
remote=None,
jobs=None,
):
@@ -21,6 +22,11 @@ def update(
if isinstance(targets, str):
targets = [targets]
if to_remote and no_download:
raise InvalidArgumentError(
"--to-remote can't be used with --no-download"
)
if not to_remote and remote:
raise InvalidArgumentError(
"--remote can't be used without --to-remote"
@@ -31,7 +37,13 @@ def update(
stages.update(self.stage.collect(target, recursive=recursive))
for stage in stages:
stage.update(rev, to_remote=to_remote, remote=remote, jobs=jobs)
stage.update(
rev,
to_remote=to_remote,
remote=remote,
no_download=no_download,
jobs=jobs,
)
dvcfile = Dvcfile(self, stage.path)
dvcfile.dump(stage)
+29 -19
View File
@@ -7,6 +7,7 @@ from voluptuous import Invalid, Optional, Required, Schema
from .exceptions import DvcException
from .fs import localfs
from .lock import make_lock
from .utils import relpath
INFO_SCHEMA = {Required("pid"): int, Required("cmd"): str}
@@ -33,22 +34,29 @@ class RWLockFileFormatError(DvcException):
@contextmanager
def _edit_rwlock(lock_dir):
path = os.path.join(lock_dir, "rwlock")
try:
with open(path, encoding="utf-8") as fobj:
lock = SCHEMA(json.load(fobj))
except FileNotFoundError:
lock = SCHEMA({})
except json.JSONDecodeError as exc:
raise RWLockFileCorruptedError(path) from exc
except Invalid as exc:
raise RWLockFileFormatError(path) from exc
lock["read"] = defaultdict(list, lock["read"])
lock["write"] = defaultdict(dict, lock["write"])
yield lock
with open(path, "w+", encoding="utf-8") as fobj:
json.dump(lock, fobj)
def _edit_rwlock(lock_dir, fs, hardlink):
path = fs.path.join(lock_dir, "rwlock")
rwlock_guard = make_lock(
fs.path.join(lock_dir, "rwlock.lock"),
tmp_dir=lock_dir,
hardlink_lock=hardlink,
)
with rwlock_guard:
try:
with fs.open(path, encoding="utf-8") as fobj:
lock = SCHEMA(json.load(fobj))
except FileNotFoundError:
lock = SCHEMA({})
except json.JSONDecodeError as exc:
raise RWLockFileCorruptedError(path) from exc
except Invalid as exc:
raise RWLockFileFormatError(path) from exc
lock["read"] = defaultdict(list, lock["read"])
lock["write"] = defaultdict(dict, lock["write"])
yield lock
with fs.open(path, "w", encoding="utf-8") as fobj:
json.dump(lock, fobj)
def _infos_to_str(infos):
@@ -133,14 +141,16 @@ def _release_read(lock, info, changes):
@contextmanager
def rwlock(tmp_dir, cmd, read, write):
def rwlock(tmp_dir, fs, cmd, read, write, hardlink):
"""Create non-thread-safe RWLock for file paths.
Args:
tmp_dir (str): existing directory where to create the rwlock file.
fs (FileSystem): fs instance that tmp_dir belongs to.
cmd (str): command that will be working on these file path.
read ([str]): file paths that are going to be read.
write ([str]): file paths that are going to be written.
hardlink (bool): use hardlink lock to guard rwlock file when on edit.
Raises:
LockError: raised if file paths we want to read is being written to by
@@ -152,7 +162,7 @@ def rwlock(tmp_dir, cmd, read, write):
"""
info = {"pid": os.getpid(), "cmd": cmd}
with _edit_rwlock(tmp_dir) as lock:
with _edit_rwlock(tmp_dir, fs, hardlink) as lock:
_check_blockers(lock, info, mode="write", waiters=read + write)
_check_blockers(lock, info, mode="read", waiters=write)
@@ -163,6 +173,6 @@ def rwlock(tmp_dir, cmd, read, write):
try:
yield
finally:
with _edit_rwlock(tmp_dir) as lock:
with _edit_rwlock(tmp_dir, fs, hardlink) as lock:
_release_write(lock, info, wchanges)
_release_read(lock, info, rchanges)
+49 -16
View File
@@ -176,20 +176,7 @@ def resolve_rev(scm: "Git", rev: str) -> str:
raise RevError(str(exc))
def iter_revs(
scm: "Git",
revs: Optional[List[str]] = None,
num: int = 1,
all_branches: bool = False,
all_tags: bool = False,
all_commits: bool = False,
all_experiments: bool = False,
) -> Mapping[str, List[str]]:
if not any([revs, all_branches, all_tags, all_commits, all_experiments]):
return {}
revs = revs or []
def _get_n_commits(scm: "Git", revs: List[str], num: int) -> List[str]:
results = []
for rev in revs:
if num == 0:
@@ -205,6 +192,37 @@ def iter_revs(
except RevError:
break
n += 1
return results
def iter_revs(
scm: "Git",
revs: Optional[List[str]] = None,
num: int = 1,
all_branches: bool = False,
all_tags: bool = False,
all_commits: bool = False,
all_experiments: bool = False,
commit_date: Optional[str] = None,
) -> Mapping[str, List[str]]:
from scmrepo.exceptions import SCMError as _SCMError
from dvc.repo.experiments.utils import exp_commits
if not any(
[
revs,
all_branches,
all_tags,
all_commits,
all_experiments,
commit_date,
]
):
return {}
revs = revs or []
results: List[str] = _get_n_commits(scm, revs, num)
if all_commits:
results.extend(scm.list_all_commits())
@@ -215,9 +233,24 @@ def iter_revs(
if all_tags:
results.extend(scm.list_tags())
if all_experiments:
from dvc.repo.experiments.utils import exp_commits
if commit_date:
from datetime import datetime
commit_datestamp = datetime.strptime(
commit_date, "%Y-%m-%d"
).timestamp()
def _time_filter(rev):
try:
if scm.resolve_commit(rev).commit_date >= commit_datestamp:
return True
return False
except _SCMError:
return True
results.extend(filter(_time_filter, scm.list_all_commits()))
if all_experiments:
results.extend(exp_commits(scm))
rev_resolver = partial(resolve_rev, scm)
+39 -7
View File
@@ -78,6 +78,7 @@ def create_stage(cls, repo, path, external=False, **kwargs):
wdir = os.path.abspath(kwargs.get("wdir", None) or os.curdir)
path = os.path.abspath(path)
check_dvcfile_path(repo, path)
check_stage_path(repo, wdir, is_wdir=kwargs.get("wdir"))
check_stage_path(repo, os.path.dirname(path))
@@ -242,6 +243,14 @@ class Stage(params.StageParams):
"""Whether the DVC file was created with `dvc import`."""
return not self.cmd and len(self.deps) == 1 and len(self.outs) == 1
@property
def is_partial_import(self) -> bool:
"""
Whether the DVC file was created using `dvc import --no-download`
or `dvc import-url --no-download`.
"""
return self.is_import and (not self.outs[0].hash_info)
@property
def is_repo_import(self):
if not self.is_import:
@@ -433,11 +442,23 @@ class Stage(params.StageParams):
return self
def update(self, rev=None, to_remote=False, remote=None, jobs=None):
def update(
self,
rev=None,
to_remote=False,
remote=None,
no_download=None,
jobs=None,
):
if not (self.is_repo_import or self.is_import):
raise StageUpdateError(self.relpath)
update_import(
self, rev=rev, to_remote=to_remote, remote=remote, jobs=jobs
self,
rev=rev,
to_remote=to_remote,
remote=remote,
no_download=no_download,
jobs=jobs,
)
def reload(self):
@@ -457,6 +478,7 @@ class Stage(params.StageParams):
def save(self, allow_missing=False):
self.save_deps(allow_missing=allow_missing)
self.save_outs(allow_missing=allow_missing)
self.md5 = self.compute_md5()
@@ -525,14 +547,17 @@ class Stage(params.StageParams):
no_commit=False,
force=False,
allow_missing=False,
no_download=False,
**kwargs,
):
if (self.cmd or self.is_import) and not self.frozen and not dry:
self.remove_outs(ignore_remove=False, force=False)
if not self.frozen and self.is_import:
if (not self.frozen and self.is_import) or self.is_partial_import:
jobs = kwargs.get("jobs", None)
self._sync_import(dry, force, jobs)
self._sync_import(
dry, force, jobs, no_download, check_changed=self.frozen
)
elif not self.frozen and self.cmd:
self._run_stage(dry, force, **kwargs)
else:
@@ -544,7 +569,7 @@ class Stage(params.StageParams):
self._check_missing_outputs()
if not dry:
if kwargs.get("checkpoint_func", None):
if kwargs.get("checkpoint_func", None) or no_download:
allow_missing = True
self.save(allow_missing=allow_missing)
if not no_commit:
@@ -555,8 +580,10 @@ class Stage(params.StageParams):
return run_stage(self, dry, force, **kwargs)
@rwlocked(read=["deps"], write=["outs"])
def _sync_import(self, dry, force, jobs):
sync_import(self, dry, force, jobs)
def _sync_import(
self, dry, force, jobs, no_download, check_changed: bool = False
):
sync_import(self, dry, force, jobs, no_download, check_changed)
@rwlocked(read=["outs"])
def _check_missing_outputs(self):
@@ -571,6 +598,8 @@ class Stage(params.StageParams):
@rwlocked(write=["outs"])
def checkout(self, allow_missing=False, **kwargs):
stats = defaultdict(list)
if self.is_partial_import:
return {}
for out in self.filter_outs(kwargs.get("filter_info")):
key, outs = self._checkout(
out,
@@ -658,6 +687,9 @@ class Stage(params.StageParams):
self, *args, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
"""Return set of object IDs used by this stage."""
if self.is_partial_import:
return {}
used_objs = defaultdict(set)
for out in self.filter_outs(kwargs.get("filter_info")):
for odb, objs in out.get_used_objs(*args, **kwargs).items():
+1 -1
View File
@@ -65,7 +65,7 @@ class StageCache:
@cached_property
def cache_dir(self):
return os.path.join(self.repo.odb.local.cache_dir, "runs")
return os.path.join(self.repo.odb.local.path, "runs")
def _get_cache_dir(self, key):
return os.path.join(self.cache_dir, key[:2], key)
+8 -1
View File
@@ -32,7 +32,14 @@ def rwlocked(call, read=None, write=None):
cmd = " ".join(sys.argv)
with rwlock(stage.repo.tmp_dir, cmd, _chain(read), _chain(write)):
with rwlock(
stage.repo.tmp_dir,
stage.repo.fs,
cmd,
_chain(read),
_chain(write),
stage.repo.config["core"].get("hardlink_lock", False),
):
return call()
+5
View File
@@ -80,6 +80,11 @@ class MissingDataSource(DvcException):
super().__init__(msg)
class DataSourceChanged(DvcException):
def __init__(self, path: str):
super().__init__(f"data source changed: {path}")
class StageNotFound(DvcException, KeyError):
def __init__(self, file, name):
self.file = file.relpath
+29 -4
View File
@@ -17,20 +17,37 @@ def _update_import_on_remote(stage, remote, jobs):
stage.outs[0].transfer(url, odb=odb, jobs=jobs, update=True)
def update_import(stage, rev=None, to_remote=False, remote=None, jobs=None):
def update_import(
stage, rev=None, to_remote=False, remote=None, no_download=None, jobs=None
):
stage.deps[0].update(rev=rev)
outs = stage.outs
deps = stage.deps
frozen = stage.frozen
stage.frozen = False
if stage.outs:
stage.outs[0].clear()
try:
if to_remote:
_update_import_on_remote(stage, remote, jobs)
else:
stage.reproduce(jobs=jobs)
stage.reproduce(no_download=no_download, jobs=jobs)
finally:
if deps == stage.deps:
stage.outs = outs
stage.frozen = frozen
def sync_import(stage, dry=False, force=False, jobs=None):
def sync_import(
stage,
dry=False,
force=False,
jobs=None,
no_download=False,
check_changed=False,
):
"""Synchronize import's outs to the workspace."""
logger.info("Importing '%s' -> '%s'", stage.deps[0], stage.outs[0])
if dry:
@@ -39,5 +56,13 @@ def sync_import(stage, dry=False, force=False, jobs=None):
if not force and stage.already_cached():
stage.outs[0].checkout()
else:
if check_changed:
old_hash_info = stage.deps[0].hash_info
stage.save_deps()
stage.deps[0].download(stage.outs[0], jobs=jobs)
if check_changed and not old_hash_info == stage.deps[0].hash_info:
from dvc.stage.exceptions import DataSourceChanged
raise DataSourceChanged(f"{stage} ({stage.deps[0]})")
if not no_download:
stage.deps[0].download(stage.outs[0], jobs=jobs)
+6 -2
View File
@@ -50,10 +50,14 @@ def fill_stage_outputs(stage, **kwargs):
keys = [
"outs_persist",
"outs_persist_no_cache",
"metrics_no_cache",
"metrics",
"plots_no_cache",
"metrics_persist",
"metrics_no_cache",
"metrics_persist_no_cache",
"plots",
"plots_persist",
"plots_no_cache",
"plots_persist_no_cache",
"outs_no_cache",
"outs",
"checkpoints",
+1 -1
View File
@@ -14,7 +14,7 @@ class TestAPI:
dvc.push()
# Remove cache to force download
remove(dvc.odb.local.cache_dir)
remove(dvc.odb.local.path)
with api.open("foo") as fd:
assert fd.read() == "foo-text"
+5 -5
View File
@@ -44,8 +44,8 @@ class TestRemote:
# Move cache and check status
# See issue https://github.com/iterative/dvc/issues/4383 for details
backup_dir = dvc.odb.local.cache_dir + ".backup"
shutil.move(dvc.odb.local.cache_dir, backup_dir)
backup_dir = dvc.odb.local.path + ".backup"
shutil.move(dvc.odb.local.path, backup_dir)
status = dvc.cloud.status(foo_hashes)
_check_status(status, missing={foo_hash})
@@ -53,8 +53,8 @@ class TestRemote:
_check_status(status_dir, missing=dir_hashes)
# Restore original cache:
remove(dvc.odb.local.cache_dir)
shutil.move(backup_dir, dvc.odb.local.cache_dir)
remove(dvc.odb.local.path)
shutil.move(backup_dir, dvc.odb.local.path)
# Push and check status
dvc.cloud.push(foo_hashes)
@@ -71,7 +71,7 @@ class TestRemote:
_check_status(status_dir, ok=dir_hashes)
# Remove and check status
remove(dvc.odb.local.cache_dir)
remove(dvc.odb.local.path)
status = dvc.cloud.status(foo_hashes)
_check_status(status, deleted={foo_hash})
-13
View File
@@ -3,7 +3,6 @@
import hashlib
import json
import logging
import math
import os
import re
import sys
@@ -59,18 +58,6 @@ def _split(list_to_split, chunk_size):
]
def _to_chunks_by_chunks_number(list_to_split, num_chunks):
chunk_size = int(math.ceil(float(len(list_to_split)) / num_chunks))
if len(list_to_split) == 1:
return [list_to_split]
if chunk_size == 0:
chunk_size = 1
return _split(list_to_split, chunk_size)
# NOTE: Check if we are in a bundle
# https://pythonhosted.org/PyInstaller/runtime-information.html
def is_binary():
-26
View File
@@ -1,26 +0,0 @@
"""
Rollbacks monkeypatching of `json.encoder` by benedict.
It monkeypatches json to use Python-based encoder instead of C-based
during import.
We rollback that monkeypatch by keeping reference to that C-based
encoder and reinstate them after importing benedict.
See: https://github.com/iterative/dvc/issues/6423
https://github.com/fabiocaccamo/python-benedict/issues/62
and the source of truth:
https://github.com/fabiocaccamo/python-benedict/blob/c98c471065/benedict/dicts/__init__.py#L282-L285
"""
from json import encoder
try:
c_make_encoder = encoder.c_make_encoder # type: ignore[attr-defined]
except AttributeError:
c_make_encoder = None
from benedict import benedict # noqa: E402
encoder.c_make_encoder = c_make_encoder # type: ignore[attr-defined]
# Please import benedict from here lazily
__all__ = ["benedict"]
+13 -26
View File
@@ -1,5 +1,5 @@
from collections import defaultdict
from typing import Any, Dict, Iterable, List
from typing import Dict, Iterable, List
def parse_params(path_params: Iterable[str]) -> List[Dict[str, List[str]]]:
@@ -17,35 +17,22 @@ def parse_params(path_params: Iterable[str]) -> List[Dict[str, List[str]]]:
return [{path: params} for path, params in ret.items()]
def loads_param_overrides(
def to_path_overrides(
path_params: Iterable[str],
) -> Dict[str, Dict[str, Any]]:
"""Loads the content of params from the cli as Python object."""
from ruamel.yaml import YAMLError
) -> Dict[str, List[str]]:
"""Group overrides by path"""
from dvc.dependency.param import ParamsDependency
from dvc.exceptions import InvalidArgumentError
from .serialize import loads_yaml
ret: Dict[str, Dict[str, Any]] = defaultdict(dict)
path_overrides = defaultdict(list)
for path_param in path_params:
param_name, _, param_value = path_param.partition("=")
if not param_value:
raise InvalidArgumentError(
f"Must provide a value for parameter '{param_name}'"
)
path, _, param_name = param_name.partition(":")
if not param_name:
param_name = path
path_and_name = path_param.partition("=")[0]
if ":" not in path_and_name:
override = path_param
path = ParamsDependency.DEFAULT_PARAMS_FILE
else:
path, _, override = path_param.partition(":")
try:
ret[path][param_name] = loads_yaml(param_value)
except (ValueError, YAMLError):
raise InvalidArgumentError(
f"Invalid parameter value for '{param_name}': '{param_value}"
)
path_overrides[path].append(override)
return ret
return dict(path_overrides)
+47 -35
View File
@@ -3,16 +3,6 @@ from collections.abc import Mapping
from functools import wraps
from typing import Callable, Dict, Iterable, List, TypeVar, Union
from dvc.exceptions import DvcException
class NewParamsFound(DvcException):
"""Thrown if new params were found during merge_params"""
def __init__(self, new_params: List, *args):
self.new_params = new_params
super().__init__("New params found during merge", *args)
def apply_diff(src, dest):
"""Recursively apply changes from src to dest.
@@ -61,6 +51,53 @@ def apply_diff(src, dest):
)
def to_omegaconf(item):
"""
Some parsers return custom classes (i.e. parse_yaml_for_update)
that can mess up with omegaconf logic.
Cast the custom classes to Python primitives.
"""
if isinstance(item, dict):
item = {k: to_omegaconf(v) for k, v in item.items()}
elif isinstance(item, list):
item = [to_omegaconf(x) for x in item]
return item
def remove_missing_keys(src, to_update):
keys = list(src.keys())
for key in keys:
if key not in to_update:
del src[key]
elif isinstance(src[key], dict):
remove_missing_keys(src[key], to_update[key])
return src
def _merge_item(d, key, value):
if key in d:
item = d.get(key, None)
if isinstance(item, dict) and isinstance(value, dict):
merge_dicts(item, value)
else:
d[key] = value
else:
d[key] = value
def merge_dicts(src: Dict, to_update: Dict) -> Dict:
"""Recursively merges dictionaries.
Args:
src (dict): source dictionary of parameters
to_update (dict): dictionary of parameters to merge into src
"""
for key, value in to_update.items():
_merge_item(src, key, value)
return src
def ensure_list(item: Union[Iterable[str], str, None]) -> List[str]:
if item is None:
return []
@@ -79,31 +116,6 @@ def chunk_dict(d: Dict[_KT, _VT], size: int = 1) -> List[Dict[_KT, _VT]]:
return [{key: d[key] for key in chunk} for chunk in chunks(size, d)]
def merge_params(src: Dict, to_update: Dict, allow_new: bool = True) -> Dict:
"""
Recursively merges params with benedict's syntax support in-place.
Args:
src (dict): source dictionary of parameters
to_update (dict): dictionary of parameters to merge into src
allow_new (bool): if False, raises an error if new keys would be
added to src
"""
from ._benedict import benedict
data = benedict(src)
if not allow_new:
new_params = list(
set(to_update.keys()) - set(data.keypaths(indexes=True))
)
if new_params:
raise NewParamsFound(new_params)
data.merge(to_update, overwrite=True)
return src
class _NamespacedDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+53
View File
@@ -0,0 +1,53 @@
from pathlib import Path
from typing import TYPE_CHECKING, List
from hydra._internal.config_loader_impl import ConfigLoaderImpl
from hydra.core.override_parser.overrides_parser import OverridesParser
from hydra.errors import ConfigCompositionException, OverrideParseException
from hydra.types import RunMode
from omegaconf import OmegaConf
from dvc.exceptions import InvalidArgumentError
from .collections import merge_dicts, remove_missing_keys, to_omegaconf
from .serialize import MODIFIERS
if TYPE_CHECKING:
from dvc.types import StrPath
def apply_overrides(path: "StrPath", overrides: List[str]) -> None:
"""Update `path` params with the provided `Hydra Override`_ patterns.
Args:
overrides: List of `Hydra Override`_ patterns.
.. _Hydra Override:
https://hydra.cc/docs/next/advanced/override_grammar/basic/
"""
suffix = Path(path).suffix.lower()
hydra_errors = (ConfigCompositionException, OverrideParseException)
modify_data = MODIFIERS[suffix]
with modify_data(path) as original_data:
try:
parser = OverridesParser.create()
parsed = parser.parse_overrides(overrides=overrides)
ConfigLoaderImpl.validate_sweep_overrides_legal(
parsed, run_mode=RunMode.RUN, from_shell=True
)
new_data = OmegaConf.create(
to_omegaconf(original_data),
flags={"allow_objects": True},
)
OmegaConf.set_struct(new_data, True)
# pylint: disable=protected-access
ConfigLoaderImpl._apply_overrides_to_config(parsed, new_data)
new_data = OmegaConf.to_object(new_data)
except hydra_errors as e:
raise InvalidArgumentError("Invalid `--set-param` value") from e
merge_dicts(original_data, new_data)
remove_missing_keys(original_data, new_data)

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