Compare commits

...
108 Commits
Author SHA1 Message Date
dependabot[bot] 787c72ea18 build(deps-dev): bump dvc-gdrive from 2.19.1 to 2.19.2
Bumps [dvc-gdrive](https://github.com/iterative/dvc-gdrive) from 2.19.1 to 2.19.2.
- [Release notes](https://github.com/iterative/dvc-gdrive/releases)
- [Commits](https://github.com/iterative/dvc-gdrive/compare/2.19.1...2.19.2)

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

Signed-off-by: dependabot[bot] <[email protected]>
2023-04-20 17:01:04 +09:00
dependabot[bot] a2b7a59322 build(deps-dev): bump dvc-s3 from 2.21 to 2.22.0
Bumps [dvc-s3](https://github.com/iterative/dvc-s3) from 2.21 to 2.22.0.
- [Release notes](https://github.com/iterative/dvc-s3/releases)
- [Commits](https://github.com/iterative/dvc-s3/compare/2.21.0...2.22.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
2023-04-20 17:00:21 +09:00
Peter Rowlands ec090c5680 exp: use new collection in show/cli commands
- Updates 'dvc exp show --json' to use new data format
2023-04-20 16:58:32 +09:00
Peter Rowlands 58f74ae543 exp: standardize exp data collection 2023-04-20 16:58:32 +09:00
Peter Rowlands 2e8bcbf719 exp: add structured datatypes for collection 2023-04-20 16:58:32 +09:00
skshetry 10f523430b exp run: do not autostage on dvc repro (#9344) 2023-04-19 16:11:13 +05:45
skshetry 5563a595d5 utils.studio: allow any base url to be set, not just the hostname (#9341)
This keeps it in symmetry with
https://github.com/iterative/dvc-studio-client/pull/28, and allows
setting the base url with path, not just limited to netloc.

This was always intended to work, urljoin with the added slash was
causing this bug.
2023-04-18 22:22:26 +05:45
skshetry dd3deb6cbd add DVC_STUDIO_URL envvar support (#9340)
add DVC_STUDIO_URL envvar
2023-04-18 09:56:17 +05:45
skshetry 7841020b2b [DO NOT MERGE] release: remove feature flags for experiment sharing (#9257)
release: remove feature flags for experiment sharing
2023-04-17 12:53:27 +05:45
dberenbaum 1c394986db fix tests 2023-04-15 23:46:47 +03:00
dberenbaum 870935ebdc import-url: make version-aware and to-remote exclusive 2023-04-15 23:46:47 +03:00
Saugat Pachhai (सौगात) 1be82fa56d tests: remove unnecessary variable interpolation
dvc.run does not expand variables. It is only done when it's actually read from dvc.yaml file.
So during dvc run, the shell will try to expand the variable, which does not exist.

I don't think that the intention was to use shell variables. But I don't think we should ever
use them, as they will fail on some shells (eg: fish) and keep the scripts as simple as possible.
2023-04-14 13:01:53 +02:00
Ruslan Kuprieiev ce0f6c187e deps: put lower bound on dvc-http
People are still running into `ImportError: cannot import name 'fsspec_loop' from 'fsspec.asyn' ` because of it.
2023-04-12 22:26:31 +03:00
pre-commit-ci[bot] 559ec392f6 [pre-commit.ci] pre-commit autoupdate (#9319)
updates:
- [github.com/charliermarsh/ruff-pre-commit: v0.0.260 → v0.0.261](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.260...v0.0.261)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-04-11 14:58:43 +03:00
Peter Rowlands c71a4082e6 testing: expose force=... in scm_add 2023-04-10 13:17:47 +03:00
Peter Rowlands ce269ffa58 exp: use explicit scm.add(force=True) when necessary
- add test for dir dep containing gitignored files
2023-04-10 13:17:47 +03:00
Peter Rowlands 61d0893d7c deps: bump scmrepo to 1.0.0 2023-04-10 13:17:47 +03:00
dependabot[bot] eef5fb9b37 build(deps-dev): bump mypy from 1.1.1 to 1.2.0 (#9313)
Bumps [mypy](https://github.com/python/mypy) from 1.1.1 to 1.2.0.
- [Release notes](https://github.com/python/mypy/releases)
- [Commits](https://github.com/python/mypy/compare/v1.1.1...v1.2.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-07 10:39:06 +05:45
Peter Rowlands f03d555747 deps: bump dvc-ssh to 2.22.1 2023-04-06 19:28:30 +09:00
skshetry a91993616f logger: add DVC_SHOW_TRACEBACK envvar to show traceback on exception (#9310)
This will come useful in debugging, where you don't have to add --verbose just to
see where it failed or go through verbose logs (which might be useful when working
with UI issues as you want to see what the user sees).

Ideally this should be handled in main when we exit with error instead of using
logger.{warning,error,exception} to log exception as the intention is to only log
last exception that makes it exit. But since we don't have that capability and
we don't abuse logging.{warning,error,exception} to log and suppress exceptions, this is a good
enough solution for now without complicating `dvc.cli.main()`.
2023-04-06 09:56:19 +05:45
skshetry 3c570a2177 exp push: improve ui (#9308)
* exp push: improve ui

* fix misc suggestions
- show url even when the refs are up-to-date.
- show url at the end
- add Git remote information in up-to-date UI message.

* fix diverged exps message
2023-04-06 09:54:39 +05:45
David de la Iglesia Castro 3312a90949 exp run: Add --copy-paths arg.
List of paths to copy inside the temp directory. Only used if `--temp` or `--queue` is specified.

Closes #5800
2023-04-05 23:17:07 +02:00
Alexander Guschin 32bebc0246 Add top-level artifacts: section (#9220)
* add artifacts section

* adding and removing artifacts

* delete code for add/remove; add tests

* fix pre-commit

* remove extra piece of writing to dvc.yaml

* remove extra piece

* simplify kwargs

* allow same names for artifacts in different dvc.yaml files

* set ID as path; don't return dvc.yaml(s) with no artifacts

* fix PR feedback

* issue warning at incorrect artifact name

* issue warning at incorrect artifact name

* reference right schema

* add r to regexp

* add tests for regexp

* set artifacts for SingleStageFile

* fix windows test

* make path required; simplify regexp and remove slash from it

* allow to use gto's artifacts.yaml

* remove extra piece of code for migration from gto
2023-04-05 13:31:35 +00:00
David de la Iglesia Castro 35648134f7 exp run: git add param files that will be modified.
Only if they are currently untracked and `--temp` or `--queue` is passed.

Closes #7842
2023-04-05 10:28:18 +02:00
Peter Rowlands df09e522ec deps: bump scmrepo minver to 0.2.1 2023-04-05 16:50:03 +09:00
Ruslan Kuprieiev 40f7f34889 api: data: set default remote instead of using it directly
Fixes #9139
Fixes #7341 (see https://github.com/iterative/dvc/pull/7363#issuecomment-1032959843)
2023-04-05 02:47:35 +03:00
skshetry cf52efb8c3 notify_refs; rename studio_url to base_url (#9303) 2023-04-04 17:43:05 +05:45
skshetry 8be71be540 utils.studio: disallow redirects, misc API refactoring (#9301) 2023-04-04 13:15:08 +05:45
pre-commit-ci[bot]andskshetry 071a5b7ba4 [pre-commit.ci] pre-commit autoupdate (#9251)
updates:
- [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0)
- [github.com/charliermarsh/ruff-pre-commit: v0.0.257 → v0.0.260](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.257...v0.0.260)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: skshetry <[email protected]>
2023-04-04 10:12:52 +05:45
dependabot[bot] ed6cf093a8 build(deps-dev): bump pylint from 2.17.1 to 2.17.2 (#9299)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.17.1 to 2.17.2.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.17.1...v2.17.2)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-04 10:11:47 +05:45
skshetry 7c3c47b4a2 bump ruff to 0.0.260 and enable bandit (#9300)
* bump ruff to 0.0.260 and enable bandit

* install toml extra for bandit

* suppress insecure use of hash md5
2023-04-04 10:01:43 +05:45
David de la Iglesia Castro 3a2259821b exp save: Include LOCK_FILE 2023-04-03 10:33:52 +02:00
Ruslan Kuprieiev 649ee67e4b config: introduce core.site_cache_dir 2023-04-03 00:29:44 +03:00
Ruslan Kuprieiev 7cdb3b0ae2 repo: site_cache_dir: isolate by user
Related #9249
2023-04-03 00:29:44 +03:00
Ruslan Kuprieiev ecdc44e010 dep: repo: use dvcfs
Makes dvc get/import/etc use dvcfs, which already supports cloud versioning,
circular imports and other stuff.

Also makes dvc import behave more like dvc import-url, so that we can use
the same existing logic for fetching those using index instead of objects.

Fixes #8789
Related iterative/studio#4782
2023-04-02 03:55:02 +03:00
David de la Iglesia Castro 24e10e0278 exp save: Track top level files 2023-03-31 19:23:40 +02:00
David de la Iglesia Castro 7c8e6e3156 exp run: Track top level files. 2023-03-31 19:23:40 +02:00
David de la Iglesia Castro d2812fa48f studio: Don't fail on empty params. 2023-03-31 19:23:40 +02:00
skshetry 086d51d9de plots json: return definition errors unrelated to renderers (#9293) 2023-03-31 16:34:26 +05:45
David de la Iglesia Castro 1d8b2e5fa6 exp run: Propagate .dvc/config.local in TempDirExecutor.
Closes #7254
2023-03-31 09:02:22 +02:00
David de la Iglesia Castro 894f530d22 deps: bump dvc-studio-client>=0.6.1,<1 2023-03-30 11:23:07 +02:00
skshetry 1b9d40960f utils.studio: reduce verbosity on exception logging (#9284) 2023-03-30 12:55:19 +05:45
skshetry 696fabc745 exp-push: show url to the Studio project (#9283) 2023-03-30 12:52:09 +05:45
skshetry 780dc72301 Revert "exp-push: show url to the Studio project" (#9282)
Revert "exp-push: show url to the Studio project (#9278)"

This reverts commit 1ca3ef14a3.
2023-03-30 08:28:44 +05:45
skshetry 1ca3ef14a3 exp-push: show url to the Studio project (#9278) 2023-03-30 08:27:47 +05:45
skshetry a6c89cac8d replace funcy.cat with itertools.chain.from_iterable (#9279)
It's just an alias to the function from itertools.
2023-03-29 20:56:26 +05:45
skshetry 593f1064cf utils.studio: refactor to return json response (#9277) 2023-03-29 20:02:40 +05:45
Ruslan Kuprieiev 35bb261b89 config: don't look for .dvc dir implicitly
Currently if dvc_dir doesn't exists (e.g. we are creating an uninitialized
repo), config will try looking for `.dvc` dir in the current directory,
which might be inside of another dvc repository resulting in mixed configs.

Use explicit `from_cwd` method to make `Config` try to find repo configs
if available to avoid the usage mixup.
2023-03-29 16:27:54 +03:00
David de la Iglesia Castro fe6d543c1f tests: Add UnresolvedExpNamesError check in test_remove_remote 2023-03-29 12:56:59 +02:00
dberenbaum 4351d32de4 exp remove: fix ui for removing from git remotes 2023-03-29 12:56:59 +02:00
skshetry efc2d7ad3d remove unused type-ignore comment (#9258) 2023-03-28 14:30:33 +05:45
skshetry 7c65739e7e refactor notify_refs_to_studio to take in repo instance as dependency (#9256) 2023-03-28 13:53:31 +05:45
skshetry cc4816f652 utils.studio: require token (#9255)
This is a simple refactoring which makes the caller to choose appropriate token instead of
the studio.notify_refs making a decision for them.
2023-03-28 13:32:05 +05:45
skshetry faa1bdfbce use git remote to find out remote repo url when pinging Studio (#9254) 2023-03-28 12:17:48 +05:45
skshetry 006b6f3513 exp push/remove: show message on failure when notifying to Studio (#9253) 2023-03-28 09:40:41 +05:45
skshetry 1bc4c1dceb exp share refactor - ping on exp:remove, do not fail on error (#9248)
studio experiments share refactor

1) Pings Studio on exp remove
2) Does not fail on any error during request.post
3) UI improvement on exp remove
2023-03-27 22:51:50 +05:45
skshetry 531b2110cf Revert "exp push: add trailing slash to the endpoint url" (#9243)
Revert "exp push: add trailing slash to the endpoint url (#9240)"

This reverts commit 5485275b92.
2023-03-27 14:19:12 +05:45
skshetry c1917ed751 output: ignore missing size from files when calculating total size (#9242)
output: ignore missing size from files when calculating hash info
2023-03-27 12:56:43 +05:45
skshetry 43195a806d plots: return errors in json format (#9146)
* plots: return errors in json format

* plots --json: move plots data into data key

* rename  revision key to rev

* move imports to top-level

* add tests
2023-03-27 12:53:43 +05:45
Ruslan Kuprieiev d41512c85c deps: bump dvc-data to 0.46.0 (#9241)
* utils: determine_linecol: ignore mypy attr-defined error

* deps: bump dvc-data to 0.46.0
2023-03-26 22:06:23 +03:00
David de la Iglesia CastroandDave Berenbaum e9fb0b92aa exp branch: Make branch arg optional.
Default to `f"{ref_info.name}-branch"` where ref_info is inferred from the `exp_rev` argument.

Co-authored-by: Dave Berenbaum <[email protected]>
2023-03-24 19:11:06 +01:00
skshetry 5485275b92 exp push: add trailing slash to the endpoint url (#9240) 2023-03-24 22:35:20 +05:45
Peter Rowlands 1f80f2f2ed deps: bump scmrepo to 0.1.17 2023-03-24 19:31:27 +09:00
David de la Iglesia Castro 6b6dc01ac4 exp save: Reintroduce implementation based on executor/queue.
Closes #9058 .

Ensures behavior of `_stash_exp` in `exp run` is matched in `exp save` without duplicating logic.

The simpler implementation ended up being worse for maintenance when wanting to make `exp run` and `exp save` behavior in sync.
2023-03-24 10:43:24 +01:00
Peter Rowlands 91d3ff0f69 exp: mark q remove test as flaky 2023-03-24 17:53:40 +09:00
dependabot[bot] 701f48197e build(deps): bump pyinstaller from 5.8.0 to 5.9.0 (#9169)
Bumps [pyinstaller](https://github.com/pyinstaller/pyinstaller) from 5.8.0 to 5.9.0.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v5.8.0...v5.9.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-23 16:45:11 +00:00
Dave Berenbaum da3bbccf66 Update duplicate output error msg (#9138)
* update dup output error msg

* update dup output error msg

* stage add: add test for duplicated output error msg

* cleanup OutputDuplicationError code
2023-03-23 15:54:44 +00:00
Ruslan Kuprieiev 40484e8d57 dvcfs: handle broken symlink/TreeError (#9231)
* plots: handle broken symlink/TreeError

* dvcfs: ls: raise FileNotFoundError on broken symlink/TreeError

* deps: bump dvc-data to 0.45.1
2023-03-23 15:38:21 +02:00
pre-commit-ci[bot]andDavid de la Iglesia Castro 48b56c83dc [pre-commit.ci] pre-commit autoupdate (#9168)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/charliermarsh/ruff-pre-commit: v0.0.254 → v0.0.257](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.254...v0.0.257)
- [github.com/codespell-project/codespell: v2.2.2 → v2.2.4](https://github.com/codespell-project/codespell/compare/v2.2.2...v2.2.4)

* pre-commit: fix ruff errors

* pre-commit: fix flake8

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: David de la Iglesia Castro <[email protected]>
2023-03-23 13:05:31 +00:00
Peter Rowlands f8bbf20b77 exp: use individual q for timeout dependent tests 2023-03-23 19:18:52 +09:00
Peter Rowlands cd2c4d0caf tests: remark flaky queue tests as xfail on windows 2023-03-23 19:18:52 +09:00
David de la Iglesia Castro 9b329d01bf api: Add exp_save. 2023-03-23 10:33:55 +01:00
dependabot[bot] 5c442cb867 build(deps-dev): bump pylint from 2.17.0 to 2.17.1 (#9229)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.17.0 to 2.17.1.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.17.0...v2.17.1)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-23 09:24:52 +01:00
Ruslan Kuprieiev 2d5c15bee1 erepo: move legacy exceptions to api (#9228)
* erepo: get rid of NoRemoteInExternalRepoError

This exception hasn't been making any sense at all since we've introduced
get and import, as they can be chained in repos.

* erepo: get rid of NoOutputInExternalRepoError

This is dead code these days.

* erepo: get rid of FileMissingError and PathMissingError

* api: preserve legacy exc wrappers
2023-03-23 01:40:42 +02:00
Peter Rowlands 7868c1301c exp: enforce waiting for task completion in CI 2023-03-22 18:58:42 +09:00
Ruslan Kuprieiev dfe76c54d7 erepo: set subrepos/uninitialized explicitly 2023-03-22 02:26:44 +02:00
Ruslan Kuprieiev 392c15e1d4 erepo: pass rev instead of fs/scm
Towards unifying `Repo.open` and `external_repo` behaviour to get rid
of the latter.
2023-03-22 00:21:25 +02:00
David de la Iglesia Castro 121d56b739 hydra: resolve OmegaConf interpolations. (#9212)
Closes #9196
2023-03-20 21:38:22 +05:45
skshetry 66406e6c99 scm: fetch_all_exps pass kwargs to scm.fetch_refspecs (#9214) 2023-03-20 21:26:24 +05:45
skshetry 8399bf612a sconfig: add support for studio token (#9213)
config: add support for studio token
2023-03-20 20:59:52 +05:45
daniele 6c1b786564 parsing: fix foreach resolution with int keys (#8904) 2023-03-20 18:40:30 +05:45
Oded Messerandpre-commit-ci[bot] d1c5fcc1b8 Add template for epic/story issues (#9208)
* Add template

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-03-20 09:56:55 +01:00
skshetry 4ee3271b0c dvc.repo.exp: import some modules lazily (#9207) 2023-03-18 17:24:01 +05:45
skshetry dae8515933 plots: small refactorings (#9206) 2023-03-17 19:42:27 +05:45
Saugat Pachhai (सौगात) f02dd83e53 close all streams in child 2023-03-17 14:43:44 +02:00
skshetry a2066d75f9 add a note why we are flushing (#9205) 2023-03-17 17:52:04 +05:45
skshetry 283d8245e1 flush streams before fork (#9203) 2023-03-17 17:14:34 +05:45
skshetry c52b12ac2a dvcfs: support caching remote streams; use that in plots (#9183)
* dvcfs: support caching remote streams; use that in plots

* plots: show progress bar, cap max_workers to 16

* bump dvc-data to 0.44.1
2023-03-17 16:04:16 +05:45
Peter Rowlands 440cb9d2bc ui: flush stdout/stderr before colorama re-init 2023-03-17 14:52:30 +09:00
David de la Iglesia Castro 2d820cccad deps: bump dvc-render>=0.3.1<0.4.0.
For https://github.com/iterative/dvc-render/pull/124
2023-03-16 21:36:02 +01:00
skshetry 30c3e3e4d4 exp push: set default endpoint (#9198) 2023-03-16 17:50:38 +05:45
skshetry 58029e9719 exp push: push experiments revs to studio (#9160)
* exp push: push experiments revs to studio

* log json data that is sent
2023-03-16 11:24:50 +05:45
Ruslan Kuprieiev 990e834f1d repo: open: handle bare git repos
If we are asked to open a potentially unitialized repo, we need to make sure
it is not a bare git repo, so that we don't accidentally start putting dvc
files inside git control dir.

Discovered while working on #8789
2023-03-16 02:03:31 +02:00
Ruslan Kuprieiev 33a2449c91 deps: repo: use _get_rev when creating dvcfs 2023-03-16 01:05:04 +02:00
Ruslan Kuprieiev e2ce95e17e deps: repo: pass url explicitly to DVCFileSystem
Otherwise our wrapper ignores it.
2023-03-16 00:35:15 +02:00
Ruslan Kuprieiev 53f9953cd1 index: data: support imports
Stepping stone to simplifying `dvc fetch/pull` by using index.

Fetch handles regular imports through index already, but not repo
imports because their processing is much more involved (e.g. chained
imports) in the current arch.

With `FileStorage` support introduced into `DataIndex` and `datafs`
supporting imports overall, `dvcfs` can now handle repo imports (even
chained ones). This will soon allow us to handle repo imports the same
way we handle regular ones, improve performance and get rid of a lot of
messy code (e.g. DependencyRepo).

Related https://github.com/iterative/scmrepo/issues/207
Related https://github.com/iterative/dvc-data/pull/315
Related https://github.com/iterative/studio/issues/5261
2023-03-15 20:32:13 +02:00
skshetry e6419b939d do not disable colorama in tests (#9187)
skip disabling colorama in tests

It might affect pytest as it wraps sys.stderr/sys.stdout for capsys fixture.
This is a temporary hack, as we might get rid of colorama soon.
2023-03-15 18:23:26 +05:45
Ruslan Kuprieiev 37ccc5e8d8 repo: create site_cache_dir with proper perms
Fixes #9184
2023-03-15 14:26:17 +02:00
skshetry 8312eb9b42 ui: disable colorama on write_json when highlighting is disabled (#9186)
On a large json output (say ~300MB), this saves about ~6 seconds of time.
2023-03-15 17:42:45 +05:45
Ruslan Kuprieiev c8745dd580 deps: bump dvc-data to 0.44.0 2023-03-15 03:41:19 +02:00
Ruslan Kuprieiev 1cf8b5e582 install: pre-commit: install all deps by default
Fixes #9178
2023-03-15 01:19:42 +02:00
Dave Berenbaum 12955da3c3 exp: fail fast in empty git repo (#8840) 2023-03-14 17:16:17 +00:00
David de la Iglesia Castro e73cc9b40a exp: Include STUDIO_REPO_URL in run_env.
Closes #8951.
2023-03-14 13:43:03 +01:00
Ruslan Kuprieiev b56f6b69dd exp: push/pull: don't include baseline commit
Fixes #6592
2023-03-14 14:00:14 +02:00
Ruslan Kuprieiev 54a6709d99 Update pyproject.toml 2023-03-13 21:03:33 +02:00
Ruslan Kuprieiev e4a5a2aec5 deps: bump dvc-data to >=0.43.0 2023-03-13 21:03:33 +02:00
skshetry 16ecd53b71 tests: remove gc.collect() from windows (#9159) 2023-03-13 13:37:57 +05:45
Ruslan Kuprieiev 612f3039e0 repo: move state/index/data_index to site_cache_dir
We use all of these for optimization and should not depend on a potentially slow or broken
filesystem on which repository resides (could be HDD or CIFS/NFS/overlayfs/etc).

This PR means a few things:
    * all odd filesystems that dvc repo might be on will work quickly out of the box
    * when we start caching cloned repositories for `dvc get/import`, we will be
      able to cache indexes for them as well (note that bare clones don't have a
      worktree so there is no place for .dvc/tmp with the old approach).
    * `index.dir` and `state.dir` are now obsolete.
    * `dvc init` will cleanup site_cache_dir.

Related #5562
2023-03-13 02:58:23 +02:00
137 changed files with 3461 additions and 2802 deletions
+60
View File
@@ -0,0 +1,60 @@
---
name: Epic/Story Issue Template
about: Template for "top" level issues - Epics (>2 weeks) / Stories (<2 weeks)
title: 'Epic: New Feature'
labels: epic
assignees:
---
## Summary / Background
What do you want to achieve, why? business context
...
## Scope
What will be impacted and what won't be?
What needs implementation and what is invariant?
e.g.
- user should be able to run workflow X from UI
- enable workflow Y, Z from CLI
## Assumptions
Product / UX assumptions as well as technical assumptions / limitations
e.g.
* Support only Python Runtime
* Focus on DVC experiments only
* Deployment environments don't change often and can be picked up from shared configuration
## Open Questions
e.g.
- How should access control work for shared artifacts (workflow X)
- Python runtime assumption - is it really valid? in light of <...>
## Blockers / Dependencies
List issues or other conditions / blockers
## General Approach
Invocation example:
```shell
$ mapper-run task.tar.gz --ray-cluster <ip>:<port>
```
## Steps
### Must have (p1)
- [ ] subissue2
- [ ] step 2
- info
- info
### Optional / followup (p2)
- [ ] ⌛ step 3 wip
- [ ] step 4
## Timelines
Put your estimations here. Update once certainty changes
- end of week (Feb 3) for prototype with workflows X, Y
- Feb 15 - MVP in production
- Low priority followups can be done later
+10 -3
View File
@@ -35,7 +35,7 @@ repos:
args:
- --py38-plus
- repo: https://github.com/psf/black
rev: 23.1.0
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
@@ -43,11 +43,11 @@ repos:
hooks:
- id: isort
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.254'
rev: 'v0.0.261'
hooks:
- id: ruff
- repo: https://github.com/codespell-project/codespell
rev: v2.2.2
rev: v2.2.4
hooks:
- id: codespell
additional_dependencies: ["tomli"]
@@ -61,6 +61,13 @@ repos:
- flake8-debugger
- flake8-string-format
- flake8-typing-imports
- repo: https://github.com/PyCQA/bandit
rev: '1.7.5'
hooks:
- id: bandit
args: ["-c", "pyproject.toml", "--quiet", "--format=custom"]
files: ^dvc/
additional_dependencies: [".[toml]"]
- repo: local
hooks:
- id: mypy
+1 -1
View File
@@ -47,7 +47,7 @@ def is_enabled():
enabled = not os.getenv(DVC_NO_ANALYTICS)
if enabled:
enabled = to_bool(
Config(validate=False).get("core", {}).get("analytics", "true")
Config.from_cwd(validate=False).get("core", {}).get("analytics", "true")
)
logger.debug("Analytics is %sabled.", "en" if enabled else "dis")
+11
View File
@@ -2,6 +2,7 @@ from dataclasses import asdict, dataclass, field, fields
from typing import Any, ClassVar, Dict, List, Optional
from funcy import compact
from voluptuous import Required
@dataclass
@@ -26,6 +27,12 @@ class Annotation:
return compact(asdict(self))
@dataclass
class Artifact(Annotation):
PARAM_PATH: ClassVar[str] = "path"
path: Optional[str] = None
ANNOTATION_FIELDS = [field.name for field in fields(Annotation)]
ANNOTATION_SCHEMA = {
Annotation.PARAM_DESC: str,
@@ -33,3 +40,7 @@ ANNOTATION_SCHEMA = {
Annotation.PARAM_LABELS: [str],
Annotation.PARAM_META: object,
}
ARTIFACT_SCHEMA = {
Required(Artifact.PARAM_PATH): str,
**ANNOTATION_SCHEMA, # type: ignore[arg-type]
}
+2 -1
View File
@@ -2,10 +2,11 @@ from dvc.fs.dvc import _DVCFileSystem as DVCFileSystem
from .data import open # pylint: disable=redefined-builtin
from .data import get_url, read
from .experiments import make_checkpoint
from .experiments import exp_save, make_checkpoint
from .show import metrics_show, params_show
__all__ = [
"exp_save",
"get_url",
"make_checkpoint",
"open",
+66 -16
View File
@@ -1,12 +1,30 @@
from contextlib import _GeneratorContextManager as GCM
from typing import Optional
from contextlib import contextmanager
from typing import Any, Dict, Optional
from funcy import reraise
from dvc.exceptions import OutputNotFoundError, PathMissingError
from dvc.exceptions import FileMissingError, OutputNotFoundError, PathMissingError
from dvc.repo import Repo
@contextmanager
def _wrap_exceptions(repo, url):
from dvc.config import NoRemoteError
from dvc.exceptions import NoOutputInExternalRepoError, NoRemoteInExternalRepoError
try:
yield
except NoRemoteError as exc:
raise NoRemoteInExternalRepoError(url) from exc
except OutputNotFoundError as exc:
if exc.repo is repo:
raise NoOutputInExternalRepoError(exc.output, repo.root_dir, url) from exc
raise
except FileMissingError as exc:
raise PathMissingError(exc.path, url) from exc
def get_url(path, repo=None, rev=None, remote=None):
"""
Returns the URL to the storage location of a data file or directory tracked
@@ -19,18 +37,20 @@ def get_url(path, repo=None, rev=None, remote=None):
directory in the remote storage.
"""
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
fs_path = _repo.dvcfs.from_os_path(path)
with reraise(FileNotFoundError, PathMissingError(path, repo)):
info = _repo.dvcfs.info(fs_path)
with _wrap_exceptions(_repo, path):
fs_path = _repo.dvcfs.from_os_path(path)
dvc_info = info.get("dvc_info")
if not dvc_info:
raise OutputNotFoundError(path, repo)
with reraise(FileNotFoundError, PathMissingError(path, repo)):
info = _repo.dvcfs.info(fs_path)
dvc_repo = info["repo"] # pylint: disable=unsubscriptable-object
md5 = dvc_info["md5"]
dvc_info = info.get("dvc_info")
if not dvc_info:
raise OutputNotFoundError(path, repo)
return dvc_repo.cloud.get_url_for(remote, checksum=md5)
dvc_repo = info["repo"] # pylint: disable=unsubscriptable-object
md5 = dvc_info["md5"]
return dvc_repo.cloud.get_url_for(remote, checksum=md5)
class _OpenContextManager(GCM):
@@ -194,11 +214,41 @@ def open( # noqa, pylint: disable=redefined-builtin
def _open(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
with _repo.open_by_relpath(
path, remote=remote, mode=mode, encoding=encoding
) as fd:
yield fd
repo_kwargs: Dict[str, Any] = {"subrepos": True, "uninitialized": True}
if remote:
repo_kwargs["config"] = {"core": {"remote": remote}}
with Repo.open(repo, rev=rev, **repo_kwargs) as _repo:
with _wrap_exceptions(_repo, path):
import os
from typing import TYPE_CHECKING, Union
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
from dvc.fs.data import DataFileSystem
from dvc.fs.dvc import DVCFileSystem
if TYPE_CHECKING:
from dvc.fs import FileSystem
fs: Union["FileSystem", DataFileSystem, DVCFileSystem]
if os.path.isabs(path):
fs = DataFileSystem(index=_repo.index.data["local"])
fs_path = path
else:
fs = DVCFileSystem(repo=_repo, subrepos=True)
fs_path = fs.from_os_path(path)
try:
with fs.open(
fs_path,
mode=mode,
encoding=encoding,
) as fobj:
yield fobj
except FileNotFoundError as exc:
raise FileMissingError(path) from exc
except IsADirectoryError as exc:
raise DvcIsADirectoryError(f"'{path}' is a directory") from exc
def read(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
+38
View File
@@ -1,6 +1,7 @@
import builtins
import os
from time import sleep
from typing import List, Optional
from dvc.env import DVC_CHECKPOINT, DVC_ROOT
from dvc.repo import Repo
@@ -31,3 +32,40 @@ def make_checkpoint():
os.fsync(fobj.fileno())
while os.path.exists(signal_file):
sleep(0.1)
def exp_save(
name: Optional[str] = None,
force: bool = False,
include_untracked: Optional[List[str]] = None,
):
"""
Create a new DVC experiment using `exp save`.
See https://dvc.org/doc/command-reference/exp/save.
Args:
name (str, optional): specify a name for this experiment.
If `None`, a default one will be generated, such as `urban-sign`.
Defaults to `None`.
force (bool): overwrite the experiment if an experiment with the same
name already exists.
Defaults to `False`.
include_untracked (List[str], optional): specify untracked file(s) to
be included in the saved experiment.
Defaults to `None`.
Returns:
str: The `Git revision`_ of the created experiment.
Raises:
ExperimentExistsError: If an experiment with `name` already exists and
`force=False`.
.. _Git revision:
https://git-scm.com/docs/revisions
"""
with Repo() as repo:
return repo.experiments.save(
name=name, force=force, include_untracked=include_untracked
)
+1 -1
View File
@@ -39,7 +39,7 @@ class CmdCheckIgnore(CmdBase):
target = input()
except (KeyboardInterrupt, EOFError):
break
if target == "":
if not target:
break
if not self._check_one_file(target):
ret = 0
+1 -1
View File
@@ -15,7 +15,7 @@ class CmdCompletion(CmdBaseNoRepo):
shell = self.args.shell
parser = self.args.parser
script = shtab.complete(parser, shell=shell, preamble=PREAMBLE)
script = shtab.complete(parser, shell=shell, preamble=PREAMBLE) # nosec B604
ui.write(script, force=True)
return 0
+1 -1
View File
@@ -32,7 +32,7 @@ class CmdConfig(CmdBaseNoRepo):
super().__init__(args)
self.config = Config(validate=False)
self.config = Config.from_cwd(validate=False)
def run(self):
if self.args.show_origin and (self.args.value or self.args.unset):
+9 -1
View File
@@ -26,5 +26,13 @@ def add_parser(experiments_subparsers, parent_parser):
experiments_branch_parser.add_argument(
"experiment", help="Experiment to be promoted."
)
experiments_branch_parser.add_argument("branch", help="Name for the new Git branch")
experiments_branch_parser.add_argument(
"branch",
nargs="?",
default=None,
help=(
"Optional name for the new Git branch. "
"Defaults to '{experiment-name}-branch'."
),
)
experiments_branch_parser.set_defaults(func=CmdExperimentsBranch)
+11
View File
@@ -19,6 +19,7 @@ class CmdExecutorRun(CmdBaseNoRepo):
queue=None,
log_level=logger.getEffectiveLevel(),
infofile=self.args.infofile,
copy_paths=self.args.copy_paths,
)
return 0
@@ -36,4 +37,14 @@ def add_parser(experiments_subparsers, parent_parser):
help="Path to executor info file",
default=None,
)
exec_run_parser.add_argument(
"-C",
"--copy-paths",
action="append",
default=[],
help=(
"List of ignored or untracked paths to copy into the temp directory."
" Only used if `--temp` or `--queue` is specified."
),
)
exec_run_parser.set_defaults(func=CmdExecutorRun)
+55 -19
View File
@@ -1,5 +1,6 @@
import argparse
import logging
from typing import Any, Dict
from dvc.cli import completion
from dvc.cli.command import CmdBase
@@ -18,29 +19,64 @@ class CmdExperimentsPush(CmdBase):
"`--rev` or `--all-commits` flag."
)
@staticmethod
def log_result(result: Dict[str, Any], remote: str):
from dvc.utils import humanize
def join_exps(exps):
return humanize.join([f"[bold]{e}[/]" for e in exps])
if diverged_exps := result.get("diverged"):
exps = join_exps(diverged_exps)
ui.error_write(
f"[yellow]Local experiment {exps} has diverged "
"from remote experiment with the same name.\n"
"To override the remote experiment re-run with '--force'.",
styled=True,
)
if uptodate_exps := result.get("up_to_date"):
exps = join_exps(uptodate_exps)
verb = "are" if len(uptodate_exps) > 1 else "is"
ui.write(
f"Experiment {exps} {verb} up to date on Git remote {remote!r}.",
styled=True,
)
if pushed_exps := result.get("success"):
exps = join_exps(pushed_exps)
ui.write(f"Pushed experiment {exps} to Git remote {remote!r}.", styled=True)
if not uptodate_exps and not pushed_exps:
ui.write("No experiments to push.")
if uploaded := result.get("uploaded"):
stats = {"uploaded": uploaded}
ui.write(humanize.get_summary(stats.items()))
if project_url := result.get("url"):
ui.write("[yellow]View your experiments at", project_url, styled=True)
def run(self):
from dvc.repo.experiments.push import UploadError
self.raise_error_if_all_disabled()
pushed_exps = self.repo.experiments.push(
self.args.git_remote,
self.args.experiment,
all_commits=self.args.all_commits,
rev=self.args.rev,
num=self.args.num,
force=self.args.force,
push_cache=self.args.push_cache,
dvc_remote=self.args.dvc_remote,
jobs=self.args.jobs,
run_cache=self.args.run_cache,
)
if pushed_exps:
ui.write(
f"Pushed experiment '{pushed_exps}'"
f"to Git remote '{self.args.git_remote}'."
try:
result = self.repo.experiments.push(
self.args.git_remote,
self.args.experiment,
all_commits=self.args.all_commits,
rev=self.args.rev,
num=self.args.num,
force=self.args.force,
push_cache=self.args.push_cache,
dvc_remote=self.args.dvc_remote,
jobs=self.args.jobs,
run_cache=self.args.run_cache,
)
else:
ui.write("No experiments to push.")
except UploadError as e:
self.log_result(e.result, self.args.git_remote)
raise
self.log_result(result, self.args.git_remote)
if not self.args.push_cache:
ui.write(
"To push cached outputs",
+7 -3
View File
@@ -24,9 +24,11 @@ class CmdExperimentsRemove(CmdBase):
)
def run(self):
from dvc.utils import humanize
self.check_arguments()
removed_list = self.repo.experiments.remove(
removed = self.repo.experiments.remove(
exp_names=self.args.experiment,
all_commits=self.args.all_commits,
rev=self.args.rev,
@@ -34,8 +36,10 @@ class CmdExperimentsRemove(CmdBase):
queue=self.args.queue,
git_remote=self.args.git_remote,
)
removed = ",".join(removed_list)
ui.write(f"Removed experiments: {removed}")
if removed:
ui.write(f"Removed experiments: {humanize.join(map(repr, removed))}")
else:
ui.write("No experiments to remove.")
return 0
+11
View File
@@ -36,6 +36,7 @@ class CmdExperimentsRun(CmdRepro):
reset=self.args.reset,
tmp_dir=self.args.tmp_dir,
machine=self.args.machine,
copy_paths=self.args.copy_paths,
**self._common_kwargs,
)
@@ -136,3 +137,13 @@ def _add_run_common(parser):
# )
# metavar="<name>",
)
parser.add_argument(
"-C",
"--copy-paths",
action="append",
default=[],
help=(
"List of ignored or untracked paths to copy into the temp directory."
" Only used if `--temp` or `--queue` is specified."
),
)
+18 -301
View File
@@ -2,9 +2,8 @@ import argparse
import logging
import os
import re
from collections import Counter, OrderedDict, defaultdict
from datetime import date, datetime
from typing import TYPE_CHECKING, Any, Dict, Set
from typing import TYPE_CHECKING, Dict, Iterable
from funcy import lmap
@@ -12,9 +11,8 @@ from dvc.cli import completion
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.commands.metrics import DEFAULT_PRECISION
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.exceptions import DvcException
from dvc.ui import ui
from dvc.utils.flatten import flatten
from dvc.utils.serialize import encode_exception
if TYPE_CHECKING:
@@ -28,30 +26,6 @@ FILL_VALUE_ERRORED = "!"
logger = logging.getLogger(__name__)
def _update_names(names, items):
for name, item in items:
item = item.get("data", {})
if isinstance(item, dict):
item = flatten(item)
names[name].update({key: None for key in item})
def _collect_names(all_experiments, **kwargs):
metric_names: Dict[str, Dict[str, Any]] = defaultdict(dict)
param_names: Dict[str, Dict[str, Any]] = defaultdict(dict)
deps_names: Set[str] = set()
for _, experiments in all_experiments.items():
for exp_data in experiments.values():
exp = exp_data.get("data", {})
_update_names(metric_names, exp.get("metrics", {}).items())
_update_names(param_names, exp.get("params", {}).items())
for dep_name in exp.get("deps", {}):
deps_names.add(dep_name)
return metric_names, param_names, sorted(deps_names)
experiment_types = {
"checkpoint_tip": "│ ╓",
"checkpoint_commit": "│ ╟",
@@ -62,229 +36,6 @@ experiment_types = {
}
def _collect_rows( # noqa: C901, PLR0912, PLR0913, PLR0915
base_rev,
experiments,
all_headers,
metric_headers,
param_headers,
metric_names,
param_names,
deps_names,
precision=DEFAULT_PRECISION,
sort_by=None,
sort_order=None,
fill_value=FILL_VALUE,
iso=False,
):
from dvc.scm import Git
if sort_by:
sort_path, sort_name, sort_type = _sort_column(
sort_by, metric_names, param_names
)
reverse = sort_order == "desc"
experiments = _sort_exp(experiments, sort_path, sort_name, sort_type, reverse)
new_checkpoint = True
for i, (rev, results) in enumerate(experiments.items()):
fill_value = FILL_VALUE_ERRORED if results.get("error") else fill_value
row_dict = {k: fill_value for k in all_headers}
exp = results.get("data", {})
state = exp.get("status")
if state == "Success":
state = fill_value
is_baseline = rev == "baseline"
if is_baseline:
name_rev = base_rev[:7] if Git.is_sha(base_rev) else base_rev
else:
name_rev = rev[:7]
tip = exp.get("checkpoint_tip")
parent_rev = exp.get("checkpoint_parent", "")
parent_exp = experiments.get(parent_rev, {}).get("data", {})
parent_tip = parent_exp.get("checkpoint_tip")
parent = ""
if is_baseline:
typ = "baseline"
elif tip:
if tip == parent_tip:
typ = "checkpoint_tip" if new_checkpoint else "checkpoint_commit"
elif parent_rev == base_rev:
typ = "checkpoint_base"
else:
typ = "checkpoint_commit"
parent = parent_rev[:7]
elif i < len(experiments) - 1:
typ = "branch_commit"
else:
typ = "branch_base"
if not is_baseline:
new_checkpoint = not (tip and tip == parent_tip)
row_dict["Experiment"] = exp.get("name", "")
row_dict["rev"] = name_rev
row_dict["typ"] = typ
row_dict["Created"] = format_time(exp.get("timestamp"), fill_value, iso)
row_dict["parent"] = parent
row_dict["State"] = state
row_dict["Executor"] = exp.get("executor", fill_value)
_extend_row(
row_dict,
metric_names,
metric_headers,
exp.get("metrics", {}).items(),
precision,
fill_value=fill_value,
)
_extend_row(
row_dict,
param_names,
param_headers,
exp.get("params", {}).items(),
precision,
fill_value=fill_value,
)
for dep in deps_names:
hash_info = exp.get("deps", {}).get(dep, {}).get("hash")
if hash_info is not None:
hash_info = hash_info[:7]
row_dict[dep] = hash_info
yield list(row_dict.values())
def _sort_column(sort_by, metric_names, param_names):
path, _, sort_name = sort_by.rpartition(":")
matches = set()
if path:
if path in metric_names and sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
if path in param_names and sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
else:
for path in metric_names:
if sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
for path in param_names:
if sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
if len(matches) == 1:
return matches.pop()
if len(matches) > 1:
raise InvalidArgumentError(
"Ambiguous sort column '{}' matched '{}'".format(
sort_by,
", ".join([f"{path}:{name}" for path, name, _ in matches]),
)
)
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
def _sort(item):
rev, exp = item
exp_data = exp.get("data", {})
tip = exp_data.get("checkpoint_tip")
if tip and tip != rev:
# Sort checkpoint experiments by tip commit
return _sort((tip, experiments[tip]))
data = exp_data.get(typ, {}).get(sort_path, {}).get("data", {})
val = flatten(data).get(sort_name)
return val is None, val
ret = OrderedDict()
if "baseline" in experiments:
ret["baseline"] = experiments.pop("baseline")
ret.update(sorted(experiments.items(), key=_sort, reverse=reverse))
return ret
def format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
if datetime_obj is None:
return fill_value
if iso:
return datetime_obj.isoformat()
if datetime_obj.date() == date.today():
fmt = "%I:%M %p"
else:
fmt = "%b %d, %Y"
return datetime_obj.strftime(fmt)
def _extend_row(row, names, headers, items, precision, fill_value=FILL_VALUE):
from dvc.compare import _format_field, with_value
for fname, data in items:
item = data.get("data", {})
item = flatten(item) if isinstance(item, dict) else {fname: item}
for name in names[fname]:
value = with_value(
item.get(name),
FILL_VALUE_ERRORED if data.get("error", None) else fill_value,
)
# wrap field data in ui.rich_text, otherwise rich may
# interpret unescaped braces from list/dict types as rich
# markup tags
value = ui.rich_text(str(_format_field(value, precision)))
if name in headers:
row[name] = value
else:
row[f"{fname}:{name}"] = value
def experiments_table( # noqa: PLR0913
all_experiments,
headers,
metric_headers,
metric_names,
param_headers,
param_names,
deps_names,
sort_by=None,
sort_order=None,
precision=DEFAULT_PRECISION,
fill_value=FILL_VALUE,
iso=False,
) -> "TabularData":
from funcy import lconcat
from dvc.compare import TabularData
all_headers = lconcat(headers, metric_headers, param_headers, deps_names)
td = TabularData(all_headers, fill_value=fill_value)
for base_rev, experiments in all_experiments.items():
rows = _collect_rows(
base_rev,
experiments,
all_headers,
metric_headers,
param_headers,
metric_names,
param_names,
deps_names,
sort_by=sort_by,
sort_order=sort_order,
precision=precision,
fill_value=fill_value,
iso=iso,
)
td.extend(rows)
return td
def prepare_exp_id(kwargs) -> "RichText":
exp_name = kwargs["Experiment"]
rev = kwargs["rev"]
@@ -309,7 +60,8 @@ def baseline_styler(typ):
def show_experiments( # noqa: C901, PLR0912
all_experiments,
td: "TabularData",
headers: Dict[str, Iterable[str]],
keep=None,
drop=None,
pager=True,
@@ -318,40 +70,6 @@ def show_experiments( # noqa: C901, PLR0912
pcp=False,
**kwargs,
):
from funcy.seqs import flatten as flatten_list
metric_names, param_names, deps_names = _collect_names(all_experiments)
headers_list = [
"Experiment",
"rev",
"typ",
"Created",
"parent",
"State",
"Executor",
]
names = {**metric_names, **param_names}
counter = Counter(flatten_list([list(a.keys()) for a in names.values()]))
counter.update(headers_list)
metric_headers = _normalize_headers(metric_names, counter)
param_headers = _normalize_headers(param_names, counter)
td = experiments_table(
all_experiments,
headers_list,
metric_headers,
metric_names,
param_headers,
param_names,
deps_names,
kwargs.get("sort_by"),
kwargs.get("sort_order"),
kwargs.get("precision"),
kwargs.get("fill_value"),
kwargs.get("iso"),
)
if keep:
for col in td.keys(): # noqa: SIM118
if re.match(keep, col):
@@ -368,11 +86,6 @@ def show_experiments( # noqa: C901, PLR0912
td.column("Experiment")[:] = map(prepare_exp_id, td.as_dict(merge_headers))
td.drop(*merge_headers[1:])
headers = {
"metrics": metric_headers,
"params": param_headers,
"deps": deps_names,
}
styles = {
"Experiment": {"no_wrap": True, "header_style": "black on grey93"},
"Created": {"header_style": "black on grey93"},
@@ -453,19 +166,17 @@ def _normalize_headers(names, count):
def _format_json(item):
from dvc.repo.experiments.show import _CachedError
if isinstance(item, (date, datetime)):
return item.isoformat()
if isinstance(item, _CachedError):
return {"type": getattr(item, "typ", "_CachedError"), "msg": str(item)}
return encode_exception(item)
class CmdExperimentsShow(CmdBase):
def run(self):
from dvc.repo.experiments.show import tabulate
try:
all_experiments = self.repo.experiments.show(
exps = self.repo.experiments.show(
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
@@ -483,23 +194,29 @@ class CmdExperimentsShow(CmdBase):
return 1
if self.args.json:
ui.write_json(all_experiments, default=_format_json)
ui.write_json([exp.dumpd() for exp in exps], default=_format_json)
else:
precision = (
self.args.precision or None if self.args.csv else DEFAULT_PRECISION
)
fill_value = "" if self.args.csv else FILL_VALUE
iso = self.args.csv
td, headers = tabulate(
exps,
precision=precision,
fill_value=fill_value,
iso=iso,
sort_by=self.args.sort_by,
sort_order=self.args.sort_order,
)
show_experiments(
all_experiments,
td,
headers,
keep=self.args.keep,
drop=self.args.drop,
sort_by=self.args.sort_by,
sort_order=self.args.sort_order,
precision=precision,
fill_value=fill_value,
iso=iso,
pager=not self.args.no_pager,
csv=self.args.csv,
markdown=self.args.markdown,
+2 -6
View File
@@ -14,12 +14,8 @@ class CmdGet(CmdBaseNoRepo):
from dvc.api import get_url
from dvc.ui import ui
try:
url = get_url(self.args.path, repo=self.args.url, rev=self.args.rev)
ui.write(url, force=True)
except DvcException:
logger.exception("failed to show URL")
return 1
url = get_url(self.args.path, repo=self.args.url, rev=self.args.rev)
ui.write(url, force=True)
return 0
+53 -10
View File
@@ -1,9 +1,9 @@
import argparse
import json
import logging
import os
from typing import TYPE_CHECKING, Dict, List, Optional
from funcy import first
from funcy import compact, first, get_in
from dvc.cli import completion
from dvc.cli.command import CmdBase
@@ -12,14 +12,51 @@ from dvc.exceptions import DvcException
from dvc.ui import ui
from dvc.utils import format_link
if TYPE_CHECKING:
from dvc.render.match import RendererWithErrors
logger = logging.getLogger(__name__)
def _show_json(renderers, split=False):
def _show_json(
renderers_with_errors: List["RendererWithErrors"],
split=False,
errors: Optional[Dict[str, Exception]] = None,
):
from dvc.render.convert import to_json
from dvc.utils.serialize import encode_exception
result = {renderer.name: to_json(renderer, split) for renderer in renderers}
ui.write_json(result, highlight=False)
all_errors: List[Dict] = []
data = {}
for renderer, src_errors, def_errors in renderers_with_errors:
name = renderer.name
data[name] = to_json(renderer, split)
all_errors.extend(
{
"name": name,
"rev": rev,
"source": source,
**encode_exception(e),
}
for rev, per_rev_src_errors in src_errors.items()
for source, e in per_rev_src_errors.items()
)
all_errors.extend(
{
"name": name,
"rev": rev,
**encode_exception(e),
}
for rev, e in def_errors.items()
)
# these errors are not tied to any renderers
errors = errors or {}
all_errors.extend({"rev": rev, **encode_exception(e)} for rev, e in errors.items())
ui.write_json(compact({"errors": all_errors, "data": data}), highlight=False)
def _adjust_vega_renderers(renderers):
@@ -85,7 +122,7 @@ class CmdPlots(CmdBase):
if html_template_path and not os.path.isabs(html_template_path):
assert self.repo.dvc_dir
html_template_path = os.path.join(self.repo.dvc_dir, html_template_path)
return html_template_path # noqa: RET504
return html_template_path
def run(self) -> int: # noqa: C901, PLR0911, PLR0912
from pathlib import Path
@@ -122,21 +159,27 @@ class CmdPlots(CmdBase):
)
renderers_out = out if self.args.json else os.path.join(out, "static")
renderers = match_defs_renderers(
renderers_with_errors = match_defs_renderers(
data=plots_data,
out=renderers_out,
templates_dir=self.repo.plots.templates_dir,
)
if self.args.json:
_show_json(renderers, self.args.split)
errors = compact(
{
rev: get_in(data, ["definitions", "error"])
for rev, data in plots_data.items()
}
)
_show_json(renderers_with_errors, self.args.split, errors=errors)
return 0
renderers = [r.renderer for r in renderers_with_errors]
_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()))
ui.write_json(renderer.get_filled_template(as_string=False))
return 0
output_file: Path = (Path.cwd() / out).resolve() / "index.html"
+3 -5
View File
@@ -1,10 +1,8 @@
import argparse
import logging
from typing import Dict, List, Optional
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.commands.experiments.show import format_time
from dvc.compare import TabularData
from dvc.ui import ui
@@ -15,9 +13,9 @@ class CmdQueueStatus(CmdBase):
"""Show queue task and worker status."""
def run(self) -> int:
result: List[
Dict[str, Optional[str]]
] = self.repo.experiments.celery_queue.status()
from dvc.repo.experiments.show import format_time
result = self.repo.experiments.celery_queue.status()
if result:
all_headers = ["Task", "Name", "Created", "Status"]
td = TabularData(all_headers)
+12 -8
View File
@@ -103,18 +103,22 @@ class Config(dict):
self.wfs = LocalFileSystem()
self.fs = fs or self.wfs
if not dvc_dir:
try:
from dvc.repo import Repo
self.dvc_dir = Repo.find_dvc_dir()
except NotDvcRepoError:
self.dvc_dir = None
else:
if dvc_dir:
self.dvc_dir = self.fs.path.abspath(self.fs.path.realpath(dvc_dir))
self.load(validate=validate, config=config)
@classmethod
def from_cwd(cls, fs: Optional["FileSystem"] = None, **kwargs):
from dvc.repo import Repo
try:
dvc_dir = Repo.find_dvc_dir(fs=fs)
except NotDvcRepoError:
dvc_dir = None
return cls(dvc_dir=dvc_dir, fs=fs, **kwargs)
@classmethod
def get_dir(cls, level):
from platformdirs import site_config_dir, user_config_dir
+10 -4
View File
@@ -62,9 +62,9 @@ def ByUrl(mapping): # noqa: N802
parsed = urlparse(data["url"])
# Windows absolute paths should really have scheme == "" (local)
if os.name == "nt" and len(parsed.scheme) == 1 and parsed.netloc == "":
if os.name == "nt" and len(parsed.scheme) == 1 and not parsed.netloc:
return schemas[""](data)
if parsed.netloc == "":
if not parsed.netloc:
return schemas[""](data)
if parsed.scheme not in schemas:
raise Invalid(f"Unsupported URL type {parsed.scheme}://")
@@ -150,6 +150,7 @@ SCHEMA = {
Optional("autostage", default=False): Bool,
Optional("experiments"): Bool, # obsoleted
Optional("check_update", default=True): Bool,
"site_cache_dir": str,
"machine": Lower,
},
"cache": {
@@ -214,6 +215,7 @@ SCHEMA = {
"timeout": Coerce(int),
"gss_auth": Bool,
"allow_agent": Bool,
"max_sessions": Coerce(int),
Optional("verify", default=False): Bool,
**REMOTE_COMMON,
},
@@ -273,12 +275,12 @@ SCHEMA = {
)
},
"state": {
"dir": str,
"dir": str, # obsoleted
"row_limit": All(Coerce(int), Range(1)), # obsoleted
"row_cleanup_quota": All(Coerce(int), Range(0, 100)), # obsoleted
},
"index": {
"dir": str,
"dir": str, # obsoleted
},
"machine": {
str: {
@@ -325,4 +327,8 @@ SCHEMA = {
"config_dir": str,
"config_name": str,
},
"studio": {
"token": str,
"url": str,
},
}
+11 -3
View File
@@ -5,7 +5,7 @@ import logging
import os
import platform
import sys
from subprocess import Popen
from subprocess import Popen # nosec B404
from typing import List
from dvc.env import DVC_DAEMON
@@ -28,12 +28,12 @@ def _popen(cmd, **kwargs) -> Popen:
os.path.abspath(os.path.dirname(__file__)), "__main__.py"
)
prefix += [main_entrypoint]
return Popen(prefix + cmd, close_fds=True, shell=False, **kwargs)
return Popen(prefix + cmd, close_fds=True, shell=False, **kwargs) # nosec B603
def _spawn_windows(cmd, env):
if sys.platform == "win32":
from subprocess import (
from subprocess import ( # nosec B404
CREATE_NEW_PROCESS_GROUP,
CREATE_NO_WINDOW,
STARTF_USESHOWWINDOW,
@@ -56,6 +56,13 @@ def _spawn_windows(cmd, env):
def _spawn_posix(cmd, env):
from dvc.cli import main
# `fork` will copy buffers, so we need to flush them before forking.
# Otherwise, we will get duplicated outputs.
if sys.stdout and not sys.stdout.closed:
sys.stdout.flush()
if sys.stderr and not sys.stderr.closed:
sys.stderr.flush()
# NOTE: using os._exit instead of sys.exit, because dvc built
# with PyInstaller has trouble with SystemExit exception and throws
# errors such as "[26338] Failed to execute script __main__"
@@ -82,6 +89,7 @@ def _spawn_posix(cmd, env):
sys.stdin.close()
sys.stdout.close()
sys.stderr.close()
os.closerange(0, 3)
if platform.system() == "Darwin":
# workaround for MacOS bug
+4 -4
View File
@@ -75,7 +75,7 @@ class DataCloud:
config["version_aware"] = True
fs = cls(**config)
config["tmp_dir"] = self.repo.index_db_dir
config["tmp_dir"] = self.repo.site_cache_dir
if self.repo.data_index is not None:
index = self.repo.data_index.view(("remote", name))
else:
@@ -84,14 +84,14 @@ class DataCloud:
if bool(self.repo.config["remote"]):
error_msg = (
"no remote specified. Setup default remote with\n"
f"no remote specified in {self.repo}. Setup default remote with\n"
" dvc remote default <remote name>\n"
"or use:\n"
" dvc {} -r <remote name>".format(command)
f" dvc {command} -r <remote name>"
)
else:
error_msg = (
"no remote specified. Create a default remote with\n"
f"no remote specified in {self.repo}. Create a default remote with\n"
" dvc remote add -d <remote name> <remote url>"
)
+21 -153
View File
@@ -1,24 +1,14 @@
import os
from collections import defaultdict
from copy import copy
from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple, Union
from typing import TYPE_CHECKING, Dict, Optional, Union
from voluptuous import Required
from dvc.prompt import confirm
from dvc.utils import as_posix
from .base import Dependency
if TYPE_CHECKING:
from typing import ContextManager
from dvc.output import Output
from dvc.repo import Repo
from dvc.fs import DVCFileSystem
from dvc.stage import Stage
from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.hashfile.meta import Meta
from dvc_data.hashfile.obj import HashFile
from dvc_objects.db import ObjectDB
class RepoDependency(Dependency):
@@ -37,10 +27,11 @@ class RepoDependency(Dependency):
def __init__(self, def_repo: Dict[str, str], stage: "Stage", *args, **kwargs):
self.def_repo = def_repo
self._objs: Dict[str, "HashFile"] = {}
self._meta: Dict[str, "Meta"] = {}
super().__init__(stage, *args, **kwargs)
self.fs = self._make_fs()
self.fs_path = as_posix(self.def_path)
def _parse_path(self, fs, fs_path): # noqa: ARG002
return None
@@ -52,8 +43,8 @@ class RepoDependency(Dependency):
return f"{self.def_path} ({self.def_repo[self.PARAM_URL]})"
def workspace_status(self):
current = self.get_obj(locked=True).hash_info
updated = self.get_obj(locked=False).hash_info
current = self._make_fs(locked=True).repo.get_rev()
updated = self._make_fs(locked=False).repo.get_rev()
if current != updated:
return {str(self): "update available"}
@@ -64,33 +55,18 @@ class RepoDependency(Dependency):
return self.workspace_status()
def save(self):
pass
rev = self.fs.repo.get_rev()
if self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = rev
def dumpd(self, **kwargs) -> Dict[str, Union[str, Dict[str, str]]]:
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
def download(self, to: "Output", jobs: Optional[int] = None):
from dvc_data.hashfile.checkout import checkout
for odb, objs in self.get_used_objs().items():
self.repo.cloud.pull(objs, jobs=jobs, odb=odb)
obj = self.get_obj()
checkout(
to.fs_path,
to.fs,
obj,
self.repo.cache.local,
ignore=None,
state=self.repo.state,
prompt=confirm,
)
def update(self, rev: Optional[str] = None):
if rev:
self.def_repo[self.PARAM_REV] = rev
with self._make_repo(locked=False) as repo:
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
self.fs = self._make_fs(rev=rev, locked=False)
self.def_repo[self.PARAM_REV_LOCK] = self.fs.repo.get_rev()
def changed_checksum(self) -> bool:
# From current repo point of view what describes RepoDependency is its
@@ -98,124 +74,16 @@ class RepoDependency(Dependency):
# immutable, hence its impossible for checksum to change.
return False
def get_used_objs(self, **kwargs) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
used, _, _ = self._get_used_and_obj(**kwargs)
return used
def _make_fs(
self, rev: Optional[str] = None, locked: bool = True
) -> "DVCFileSystem":
from dvc.fs import DVCFileSystem
def _get_used_and_obj(
self, obj_only: bool = False, **kwargs
) -> Tuple[Dict[Optional["ObjectDB"], Set["HashInfo"]], "Meta", "HashFile"]:
from dvc.config import NoRemoteError
from dvc.exceptions import NoOutputOrStageError, PathMissingError
from dvc.utils import as_posix
from dvc_data.hashfile.build import build
from dvc_data.hashfile.tree import Tree, TreeError
local_odb = self.repo.cache.local
locked = kwargs.pop("locked", True)
with self._make_repo(locked=locked, cache_dir=local_odb.path) as repo:
used_obj_ids = defaultdict(set)
rev = repo.get_rev()
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = rev
if not obj_only:
try:
for odb, obj_ids in repo.used_objs(
[os.path.join(repo.root_dir, self.def_path)],
force=True,
jobs=kwargs.get("jobs"),
recursive=True,
).items():
if odb is None:
odb = repo.cloud.get_remote_odb()
odb.read_only = True
self._check_circular_import(odb, obj_ids)
used_obj_ids[odb].update(obj_ids)
except (NoRemoteError, NoOutputOrStageError):
pass
try:
object_store, meta, obj = build(
local_odb,
as_posix(self.def_path),
repo.dvcfs,
local_odb.fs.PARAM_CHECKSUM,
)
except (FileNotFoundError, TreeError) as exc:
raise PathMissingError(
self.def_path, self.def_repo[self.PARAM_URL]
) from exc
object_store = copy(object_store)
object_store.read_only = True
self._objs[rev] = obj
self._meta[rev] = meta
used_obj_ids[object_store].add(obj.hash_info)
if isinstance(obj, Tree):
used_obj_ids[object_store].update(oid for _, _, oid in obj)
return used_obj_ids, meta, obj
def _check_circular_import(self, odb: "ObjectDB", obj_ids: Set["HashInfo"]) -> None:
from dvc.exceptions import CircularImportError
from dvc.fs.dvc import DVCFileSystem
from dvc_data.hashfile.db.reference import ReferenceHashFileDB
from dvc_data.hashfile.tree import Tree
if not isinstance(odb, ReferenceHashFileDB):
return
def iter_objs():
for hash_info in obj_ids:
if hash_info.isdir:
tree = Tree.load(odb, hash_info)
yield from (odb.get(hi.value) for _, _, hi in tree)
else:
assert hash_info.value
yield odb.get(hash_info.value)
checked_urls = set()
for obj in iter_objs():
if not isinstance(obj.fs, DVCFileSystem):
continue
if obj.fs.repo_url in checked_urls or obj.fs.repo.root_dir in checked_urls:
continue
self_url = self.repo.url or self.repo.root_dir
if (
obj.fs.repo_url is not None
and obj.fs.repo_url == self_url
or obj.fs.repo.root_dir == self.repo.root_dir
):
raise CircularImportError(self, obj.fs.repo_url, self_url)
checked_urls.update([obj.fs.repo_url, obj.fs.repo.root_dir])
def get_obj(self, filter_info=None, **kwargs):
locked = kwargs.get("locked", True)
rev = self._get_rev(locked=locked)
if rev in self._objs:
return self._objs[rev]
_, _, obj = self._get_used_and_obj(
obj_only=True, filter_info=filter_info, **kwargs
return DVCFileSystem(
url=self.def_repo[self.PARAM_URL],
rev=rev or self._get_rev(locked=locked),
subrepos=True,
)
return obj
def get_meta(self, filter_info=None, **kwargs):
locked = kwargs.get("locked", True)
rev = self._get_rev(locked=locked)
if rev in self._meta:
return self._meta[rev]
_, meta, _ = self._get_used_and_obj(
obj_only=True, filter_info=filter_info, **kwargs
)
return meta
def _make_repo(self, locked: bool = True, **kwargs) -> "ContextManager[Repo]":
from dvc.external_repo import external_repo
d = self.def_repo
rev = self._get_rev(locked=locked)
return external_repo(d[self.PARAM_URL], rev=rev, **kwargs)
def _get_rev(self, locked: bool = True):
d = self.def_repo
+6 -1
View File
@@ -192,6 +192,7 @@ class SingleStageFile(FileMixin):
metrics: List[str] = []
plots: Any = {}
params: List[str] = []
artifacts: Dict[str, Optional[Dict[str, Any]]] = {}
@property
def stage(self) -> "Stage":
@@ -323,6 +324,10 @@ class ProjectFile(FileMixin):
def params(self) -> List[str]:
return self.contents.get("params", [])
@property
def artifacts(self) -> Dict[str, Optional[Dict[str, Any]]]:
return self.contents.get("artifacts", {})
def remove(self, force=False):
if not force:
logger.warning("Cannot remove pipeline file.")
@@ -366,7 +371,7 @@ def get_lockfile_schema(d):
def migrate_lock_v1_to_v2(d, version_info):
stages = {k: v for k, v in d.items()}
stages = dict(d)
for key in stages:
d.pop(key)
+3
View File
@@ -9,3 +9,6 @@ DVC_EXP_NAME = "DVC_EXP_NAME"
DVC_EXP_GIT_REMOTE = "DVC_EXP_GIT_REMOTE"
DVC_EXP_AUTO_PUSH = "DVC_EXP_AUTO_PUSH"
DVC_NO_ANALYTICS = "DVC_NO_ANALYTICS"
DVC_SHOW_TRACEBACK = "DVC_SHOW_TRACEBACK"
DVC_STUDIO_TOKEN = "DVC_STUDIO_TOKEN" # noqa: S105 # nosec B105
DVC_STUDIO_URL = "DVC_STUDIO_URL"
+9 -12
View File
@@ -31,14 +31,19 @@ class OutputDuplicationError(DvcException):
assert isinstance(output, str)
assert all(hasattr(stage, "relpath") for stage in stages)
msg = ""
stage_names = [s.addressing for s in stages]
stages_str = " ".join(stage_names)
if len(stages) == 1:
msg = "output '{}' is already specified in {}.".format(
output, first(stages)
)
stage_name = first(stages)
msg = f"output '{output}' is already specified in {stage_name}."
else:
msg = "output '{}' is already specified in stages:\n{}".format(
output, "\n".join(f"\t- {s.addressing}" for s in stages)
output, "\n".join(f"\t- {s}" for s in stage_names)
)
msg += (
f"\nUse `dvc remove {stages_str}` to stop tracking the overlapping output."
)
super().__init__(msg)
self.stages = stages
self.output = output
@@ -333,14 +338,6 @@ class CacheLinkError(DvcException):
self.fs_paths = fs_paths
class CircularImportError(DvcException):
def __init__(self, dep, a, b):
super().__init__(
f"'{dep}' contains invalid circular import. "
f"DVC repo '{a}' already imports from '{b}'."
)
class PrettyDvcException(DvcException):
def __pretty_exc__(self, **kwargs):
"""Print prettier exception message."""
+9 -37
View File
@@ -7,14 +7,7 @@ from typing import TYPE_CHECKING, Collection, Dict, Iterator, Optional, Tuple
from funcy import retry, wrap_with
from dvc.exceptions import (
FileMissingError,
NoOutputInExternalRepoError,
NoRemoteInExternalRepoError,
NotDvcRepoError,
OutputNotFoundError,
PathMissingError,
)
from dvc.exceptions import NotDvcRepoError
from dvc.repo import Repo
from dvc.scm import CloneError, map_scm_exception
from dvc.utils import relpath
@@ -37,10 +30,6 @@ def external_repo(
cache_types: Optional[Collection[str]] = None,
**kwargs,
) -> Iterator["Repo"]:
from dvc.config import NoRemoteError
from dvc.fs import GitFileSystem
from dvc.scm import Git
logger.debug("Creating external repo %s@%s", url, rev)
path = _cached_clone(url, rev, for_write=for_write)
# Local HEAD points to the tip of whatever branch we first cloned from
@@ -54,44 +43,27 @@ def external_repo(
config = _get_remote_config(url) if os.path.isdir(url) else {}
config.update(cache_config)
config.update(kwargs.pop("config", None) or {})
main_root = "/"
if for_write:
scm = None
root_dir = path
fs = None
else:
scm = Git(path)
fs = GitFileSystem(scm=scm, rev=rev)
root_dir = "/"
# we already checked out needed revision
rev = None
main_root = path
repo_kwargs = dict(
root_dir=root_dir,
root_dir=path,
url=url,
fs=fs,
config=config,
repo_factory=erepo_factory(url, root_dir, cache_config),
scm=scm,
repo_factory=erepo_factory(url, main_root, cache_config),
rev=rev,
**kwargs,
)
if "subrepos" not in repo_kwargs:
repo_kwargs["subrepos"] = True
if "uninitialized" not in repo_kwargs:
repo_kwargs["uninitialized"] = True
repo = Repo(**repo_kwargs)
try:
yield repo
except NoRemoteError as exc:
raise NoRemoteInExternalRepoError(url) from exc
except OutputNotFoundError as exc:
if exc.repo is repo:
raise NoOutputInExternalRepoError(exc.output, repo.root_dir, url) from exc
raise
except FileMissingError as exc:
raise PathMissingError(exc.path, url) from exc
finally:
repo.close()
if for_write:
+17 -4
View File
@@ -251,12 +251,15 @@ class _DVCFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
try:
return self.repo.fs.open(fs_path, mode=mode)
except FileNotFoundError:
_, dvc_fs, subkey = self._get_subrepo_info(key)
repo, dvc_fs, subkey = self._get_subrepo_info(key)
if not dvc_fs:
raise
dvc_path = _get_dvc_path(dvc_fs, subkey)
return dvc_fs.open(dvc_path, mode=mode)
kw = {}
if kwargs.get("cache_remote_stream", False):
kw["cache_odb"] = repo.cache.local
return dvc_fs.open(dvc_path, mode=mode, **kw)
def isdvc(self, path, **kwargs) -> bool:
"""Is this entry dvc-tracked?"""
@@ -265,19 +268,22 @@ class _DVCFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
dvc_path = _get_dvc_path(dvc_fs, subkey)
return dvc_fs is not None and dvc_fs.isdvc(dvc_path, **kwargs)
def ls( # pylint: disable=arguments-differ
def ls( # pylint: disable=arguments-differ # noqa: C901
self, path, detail=True, dvc_only=False, **kwargs
):
key = self._get_key_from_relative(path)
repo, dvc_fs, subkey = self._get_subrepo_info(key)
dvc_exists = False
dvc_infos = {}
if dvc_fs:
dvc_path = _get_dvc_path(dvc_fs, subkey)
with suppress(FileNotFoundError):
dvc_path = _get_dvc_path(dvc_fs, subkey)
for info in dvc_fs.ls(dvc_path, detail=True):
dvc_infos[dvc_fs.path.name(info["name"])] = info
dvc_exists = bool(dvc_infos) or dvc_fs.exists(dvc_path)
fs_exists = False
fs_infos = {}
ignore_subrepos = kwargs.get("ignore_subrepos", True)
if not dvc_only:
@@ -291,11 +297,18 @@ class _DVCFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
except (FileNotFoundError, NotADirectoryError):
pass
fs_exists = bool(fs_infos) or fs.exists(fs_path)
dvcfiles = kwargs.get("dvcfiles", False)
infos = []
paths = []
names = set(dvc_infos.keys()) | set(fs_infos.keys())
if not names and (dvc_exists or fs_exists):
# broken symlink or TreeError
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), path)
for name in names:
if not dvcfiles and _is_dvc_file(name):
continue
+2 -1
View File
@@ -54,6 +54,7 @@ def get_dvc_info():
fs_root = get_fs_type(os.path.abspath(root_directory))
info.append(f"Workspace directory: {fs_root}")
info.append(f"Repo: {_get_dvc_repo_info(repo)}")
info.append(f"Repo.site_cache_dir: {repo.site_cache_dir}")
except NotDvcRepoError:
pass
except SCMError:
@@ -138,7 +139,7 @@ def _get_supported_remotes():
def get_fs_type(path):
partition = {}
for part in psutil.disk_partitions(all=True):
if part.fstype != "":
if part.fstype:
try:
mountpoint = pathlib.Path(part.mountpoint).resolve()
partition[mountpoint] = part.fstype + " on " + part.device
+3 -1
View File
@@ -187,7 +187,9 @@ class HardlinkLock(flufl.lock.Lock, LockBase):
if self._tmp_dir is not None:
# Under Windows file path length is limited so we hash it
hasher = hashlib.md5(self._claimfile.encode()) # noqa: S324
hasher = hashlib.md5( # nosec B324, B303 # noqa: S324
self._claimfile.encode()
)
filename = hasher.hexdigest()
self._claimfile = os.path.join(self._tmp_dir, filename + ".lock")
+13 -4
View File
@@ -3,10 +3,12 @@
import logging
import logging.config
import logging.handlers
import os
import sys
import colorama
from dvc.env import DVC_SHOW_TRACEBACK
from dvc.progress import Tqdm
@@ -86,9 +88,10 @@ class ColorFormatter(logging.Formatter):
"CRITICAL": colorama.Fore.RED,
}
def __init__(self, log_colors: bool = True) -> None:
def __init__(self, log_colors: bool = True, show_traceback: bool = False) -> None:
super().__init__()
self.log_colors = log_colors
self.show_traceback = show_traceback
def format(self, record) -> str: # noqa: A003, C901
record.message = record.getMessage()
@@ -106,8 +109,10 @@ class ColorFormatter(logging.Formatter):
msg = msg + sep + cause
asctime = ""
if _is_verbose():
verbose = _is_verbose()
if verbose:
asctime = self.formatTime(record, self.datefmt)
if verbose or self.show_traceback:
if ei and not record.exc_text:
record.exc_text = self.formatException(ei)
if record.exc_text:
@@ -146,7 +151,8 @@ class LoggerHandler(logging.StreamHandler):
self.emit_pretty_exception(exc, verbose=_is_verbose())
if not _is_verbose():
return
except Exception: # noqa, pylint: disable=broad-except
# pylint: disable-next=broad-except
except Exception: # noqa: BLE001, S110 # nosec B110
pass
msg = self.format(record)
@@ -197,7 +203,10 @@ def setup(level: int = logging.INFO, log_colors: bool = True) -> None:
console_trace.setFormatter(formatter)
console_trace.addFilter(exclude_filter(logging.DEBUG))
err_formatter = ColorFormatter(log_colors=log_colors and sys.stderr.isatty())
show_traceback = bool(os.environ.get(DVC_SHOW_TRACEBACK))
err_formatter = ColorFormatter(
log_colors=log_colors and sys.stderr.isatty(), show_traceback=show_traceback
)
console_errors = LoggerHandler(sys.stderr)
console_errors.setLevel(logging.WARNING)
console_errors.setFormatter(err_formatter)
+3 -14
View File
@@ -427,7 +427,7 @@ class Output:
self.hash_info = tree.hash_info
self.meta.isdir = True
self.meta.nfiles = len(self.files)
self.meta.size = sum(f.get("size") for f in self.files)
self.meta.size = sum(filter(None, (f.get("size") for f in self.files)))
self.meta.remote = first(f.get("remote") for f in self.files)
elif self.meta.nfiles or self.hash_info and self.hash_info.isdir:
self.meta.isdir = True
@@ -1066,7 +1066,7 @@ class Output:
return obj.filter(prefix)
return obj
def get_used_objs( # noqa: C901, PLR0911
def get_used_objs( # noqa: C901
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
"""Return filtered set of used object IDs for this out."""
@@ -1076,9 +1076,7 @@ class Output:
push: bool = kwargs.pop("push", False)
if self.stage.is_repo_import:
if push:
return {}
return self.get_used_external(**kwargs)
return {}
if push and not self.can_push:
return {}
@@ -1130,15 +1128,6 @@ class Output:
oids.add(oid)
return oids
def get_used_external(
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
if not self.use_cache or not self.stage.is_repo_import:
return {}
(dep,) = self.stage.deps
return dep.get_used_objs(**kwargs)
def _validate_output_path(self, path, stage=None):
from dvc.dvcfile import is_valid_filename
+1 -1
View File
@@ -392,7 +392,7 @@ class ForeachDefinition:
"""Convert sequence to Mapping with keys normalized."""
iterable = self.resolved_iterable
if isinstance(iterable, Mapping):
return iterable
return {to_str(k): v for k, v in iterable.items()}
assert isinstance(iterable, Sequence)
if any(map(is_map_or_seq, iterable)):
+2 -2
View File
@@ -73,7 +73,7 @@ def embrace(s: str):
def escape_str(value):
if os.name == "nt":
from subprocess import list2cmdline
from subprocess import list2cmdline # nosec B404
return list2cmdline([value])
from shlex import quote
@@ -95,7 +95,7 @@ def _(obj: bool):
def _(obj: dict): # noqa: C901
from dvc.config import Config
config = Config().get("parsing", {})
config = Config.from_cwd().get("parsing", {})
result = ""
for k, v in flatten(obj).items():
+5 -4
View File
@@ -1,4 +1,3 @@
import json
from collections import defaultdict
from typing import Dict, List, Union
@@ -32,15 +31,17 @@ def to_json(renderer, split: bool = False) -> List[Dict]:
if renderer.TYPE == "vega":
grouped = _group_by_rev(renderer.datapoints)
if split:
content = renderer.get_filled_template(skip_anchors=["data"])
content = renderer.get_filled_template(
skip_anchors=["data"], as_string=False
)
else:
content = renderer.get_filled_template()
content = renderer.get_filled_template(as_string=False)
if grouped:
return [
{
TYPE_KEY: renderer.TYPE,
REVISIONS_KEY: sorted(grouped.keys()),
"content": json.loads(content),
"content": content,
"datapoints": grouped,
}
]
+1 -2
View File
@@ -56,7 +56,6 @@ def _verify_field(file2datapoints: Dict[str, List], filename: str, field: str):
datapoint = first(file2datapoints[filename])
if field not in datapoint:
raise FieldNotFoundError(field, datapoint.keys())
return
def _get_xs(properties: Dict, file2datapoints: Dict[str, List[Dict]]):
@@ -245,7 +244,7 @@ class VegaConverter(Converter):
for i, (y_file, y_field) in enumerate(ys):
if num_xs > 1:
x_file, x_field = xs[i]
datapoints = [d.copy() for d in file2datapoints.get(y_file, [])]
datapoints = [{**d} for d in file2datapoints.get(y_file, [])]
if props_update.get("y", None) == "dvc_inferred_y_value":
_update_from_field(
+29 -6
View File
@@ -1,10 +1,10 @@
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, DefaultDict, Dict, List, NamedTuple, Optional
import dpath
import dpath.options
from funcy import last
from funcy import get_in, last
from dvc.repo.plots import _normpath, infer_data_sources
from dvc.utils.plots import get_plot_id
@@ -13,6 +13,8 @@ from .convert import _get_converter
if TYPE_CHECKING:
from dvc.types import StrPath
from dvc_render.base import Renderer
dpath.options.ALLOW_EMPTY_STRING_KEYS = True
@@ -61,21 +63,31 @@ class PlotsData:
return result
def match_defs_renderers(
class RendererWithErrors(NamedTuple):
renderer: "Renderer"
source_errors: Dict[str, Dict[str, Exception]]
definition_errors: Dict[str, Exception]
def match_defs_renderers( # noqa: C901, PLR0912
data,
out=None,
templates_dir: Optional["StrPath"] = None,
):
) -> List[RendererWithErrors]:
from dvc_render import ImageRenderer, VegaRenderer
plots_data = PlotsData(data)
renderers = []
renderer_cls = None
for plot_id, group in plots_data.group_definitions().items():
plot_datapoints: List[Dict] = []
props = _squash_plots_properties(group)
final_props: Dict = {}
def_errors: Dict[str, Exception] = {}
src_errors: DefaultDict[str, Dict[str, Exception]] = defaultdict(dict)
if out is not None:
props["out"] = out
if templates_dir is not None:
@@ -94,13 +106,24 @@ def match_defs_renderers(
converter = _get_converter(renderer_cls, inner_id, props, definitions_data)
dps, rev_props = converter.flat_datapoints(rev)
for src in plot_sources:
if error := get_in(data, [rev, "sources", "data", src, "error"]):
src_errors[rev][src] = error
try:
dps, rev_props = converter.flat_datapoints(rev)
except Exception as e: # noqa: BLE001, pylint: disable=broad-except
def_errors[rev] = e
continue
if not final_props and rev_props:
final_props = rev_props
plot_datapoints.extend(dps)
if "title" not in final_props:
final_props["title"] = renderer_id
if renderer_cls is not None:
renderers.append(renderer_cls(plot_datapoints, renderer_id, **final_props))
renderer = renderer_cls(plot_datapoints, renderer_id, **final_props)
renderers.append(RendererWithErrors(renderer, dict(src_errors), def_errors))
return renderers
+42 -74
View File
@@ -14,8 +14,6 @@ from typing import (
Union,
)
from dvc.exceptions import FileMissingError
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
from dvc.exceptions import NotDvcRepoError, OutputNotFoundError
from dvc.ignore import DvcIgnoreFilter
from dvc.utils import env2bool
@@ -125,6 +123,8 @@ class Repo:
if not scm:
try:
scm = SCM(root_dir or os.curdir)
if scm.dulwich.repo.bare:
raise NotDvcRepoError(f"{scm.root_dir} is a bare git repo")
except SCMError:
scm = SCM(os.curdir, no_scm=True)
@@ -134,38 +134,6 @@ class Repo:
assert root_dir
return root_dir, dvc_dir
def _get_database_dir(self, db_name: str) -> Optional[str]:
from dvc.fs import localfs
# NOTE: by default, store SQLite-based remote indexes and state's
# `links` and `md5s` caches in the repository itself to avoid any
# possible state corruption in 'shared cache dir' scenario, but allow
# user to override this through config when, say, the repository is
# located on a mounted volume — see
# https://github.com/iterative/dvc/issues/4420
base_db_dir = self.config.get(db_name, {}).get("dir", None)
if not base_db_dir:
return self.tmp_dir
import hashlib
if self.local_dvc_dir:
fs: "FileSystem" = localfs
local_root = fs.path.parent(self.local_dvc_dir)
else:
fs = self.fs
local_root = self.root_dir
root_dir_hash = hashlib.sha224(local_root.encode("utf-8")).hexdigest()
db_dir = fs.path.join(
base_db_dir,
self.DVC_DIR,
f"{fs.path.name(local_root)}-{root_dir_hash[0:7]}",
)
fs.makedirs(db_dir, exist_ok=True)
return db_dir
def __init__( # noqa: PLR0915
self,
root_dir: Optional[str] = None,
@@ -183,6 +151,7 @@ class Repo:
from dvc.data_cloud import DataCloud
from dvc.fs import GitFileSystem, LocalFileSystem, localfs
from dvc.lock import LockNoop, make_lock
from dvc.repo.artifacts import Artifacts
from dvc.repo.metrics import Metrics
from dvc.repo.params import Params
from dvc.repo.plots import Plots
@@ -241,8 +210,8 @@ class Repo:
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)
os.makedirs(self.site_cache_dir, exist_ok=True)
self.state = State(self.root_dir, self.site_cache_dir, self.dvcignore)
else:
self.lock = LockNoop()
self.state = StateNoop()
@@ -256,6 +225,7 @@ class Repo:
self.metrics: Metrics = Metrics(self)
self.plots: Plots = Plots(self)
self.params: Params = Params(self)
self.artifacts: Artifacts = Artifacts(self)
self.stage_collection_error_handler: Optional[
Callable[[str, Exception], None]
@@ -401,13 +371,9 @@ class Repo:
from dvc_data.index import DataIndex
if self._data_index is None:
if self.index_db_dir:
index_dir = os.path.join(self.index_db_dir, "index", "data")
os.makedirs(index_dir, exist_ok=True)
self._data_index = DataIndex.open(os.path.join(index_dir, "db.db"))
else:
self._data_index = DataIndex()
index_dir = os.path.join(self.site_cache_dir, "index", "data")
os.makedirs(index_dir, exist_ok=True)
self._data_index = DataIndex.open(os.path.join(index_dir, "db.db"))
return self._data_index
@@ -444,9 +410,12 @@ class Repo:
raise NotDvcRepoError(msg)
@classmethod
def find_dvc_dir(cls, root=None) -> str:
root_dir = cls.find_root(root)
return os.path.join(root_dir, cls.DVC_DIR)
def find_dvc_dir(cls, root=None, fs=None) -> str:
from dvc.fs import localfs
fs = fs or localfs
root_dir = cls.find_root(root, fs=fs)
return fs.path.join(root_dir, cls.DVC_DIR)
@staticmethod
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False) -> "Repo":
@@ -590,40 +559,39 @@ class Repo:
return DVCFileSystem(repo=self, subrepos=self.subrepos, **self._fs_conf)
@cached_property
def index_db_dir(self):
return self._get_database_dir("index")
def site_cache_dir(self) -> str:
import getpass
import hashlib
@contextmanager
def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
"""Opens a specified resource as a file descriptor"""
from dvc.fs.data import DataFileSystem
from dvc.fs.dvc import DVCFileSystem
import platformdirs
fs: Union["FileSystem", DataFileSystem, DVCFileSystem]
if os.path.isabs(path):
fs = DataFileSystem(index=self.index.data["local"])
fs_path = path
from dvc.fs import GitFileSystem
default = platformdirs.site_cache_dir("dvc", "iterative", opinion=True)
cache_dir = self.config["core"].get("site_cache_dir") or default
if isinstance(self.fs, GitFileSystem):
relparts = ()
if self.root_dir != "/":
# subrepo
relparts = self.fs.path.relparts(self.root_dir, "/")
root_dir = os.path.join(self.scm.root_dir, *relparts)
else:
fs = DVCFileSystem(repo=self, subrepos=True)
fs_path = fs.from_os_path(path)
root_dir = self.root_dir
repos_dir = os.path.join(cache_dir, "repo")
umask = os.umask(0)
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)
os.makedirs(repos_dir, mode=0o777, exist_ok=True)
finally:
os.umask(umask)
with fs.open(
fs_path,
mode=mode,
encoding=encoding,
) as fobj:
yield fobj
except FileNotFoundError as exc:
raise FileMissingError(path) from exc
except IsADirectoryError as exc:
raise DvcIsADirectoryError(f"'{path}' is a directory") from exc
md5 = hashlib.md5( # noqa: S324 # nosec B324, B303
str((root_dir, getpass.getuser())).encode()
)
repo_token = md5.hexdigest()
return os.path.join(repos_dir, repo_token)
def close(self):
self.scm.close()
+67
View File
@@ -0,0 +1,67 @@
import logging
import os
import re
from typing import TYPE_CHECKING, Dict
from dvc.annotations import Artifact
from dvc.dvcfile import FileMixin
from dvc.utils import relpath
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
NAME_RE = re.compile(r"^[a-z]([a-z0-9-]*[a-z0-9])?$")
def name_is_compatible(name: str) -> bool:
return bool(NAME_RE.search(name))
def check_name_format(name: str) -> None:
if not name_is_compatible(name):
logger.warning(
"Can't use '%s' as artifact name (ID)."
" You can use letters and numbers, and use '-' as separator"
" (but not at the start or end). The first character must be a letter.",
name,
)
class ArtifactsFile(FileMixin):
from dvc.schema import SINGLE_ARTIFACT_SCHEMA as SCHEMA
def dump(self, stage, **kwargs):
raise NotImplementedError
def merge(self, ancestor, other, allowed=None):
raise NotImplementedError
class Artifacts:
def __init__(self, repo: "Repo") -> None:
self.repo = repo
def read(self) -> Dict[str, Dict[str, Artifact]]:
artifacts: Dict[str, Dict[str, Artifact]] = {}
for (
dvcfile,
dvcfile_artifacts,
) in self.repo.index._artifacts.items(): # pylint: disable=protected-access
# read the artifacts.yaml file if needed
if isinstance(dvcfile_artifacts, str):
dvcfile_artifacts = ArtifactsFile(
self.repo,
os.path.join(os.path.dirname(dvcfile), dvcfile_artifacts),
verify=False,
).load()
if not dvcfile_artifacts:
continue
dvcyaml = relpath(dvcfile, self.repo.root_dir)
artifacts[dvcyaml] = {}
for name, value in dvcfile_artifacts.items():
check_name_format(name)
artifacts[dvcyaml][name] = Artifact(**value)
return artifacts
+2 -2
View File
@@ -1,9 +1,9 @@
import errno
import logging
import os
from collections import defaultdict
from typing import Dict, List, Optional
from dvc.exceptions import PathMissingError
from dvc.repo import locked
from dvc.ui import ui
@@ -154,7 +154,7 @@ def diff(
# check for overlapping missing targets between a_rev and b_rev
for target in old_missing & new_missing:
raise PathMissingError(target, self)
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), target)
old = indexes[a_rev]
new = indexes[b_rev]
+27 -14
View File
@@ -1,8 +1,7 @@
import logging
import os
import re
import time
from typing import TYPE_CHECKING, Dict, Iterable, Optional
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional
from funcy import chain, first
@@ -18,10 +17,6 @@ from .exceptions import (
InvalidExpRefError,
MultipleBranchError,
)
from .executor.base import BaseExecutor
from .queue.celery import LocalCeleryQueue
from .queue.tempdir import TempDirQueue
from .queue.workspace import WorkspaceQueue
from .refs import (
APPLY_STASH,
CELERY_FAILED_STASH,
@@ -38,6 +33,9 @@ from .utils import check_ref_format, exp_refs_by_rev, unlocked_repo
if TYPE_CHECKING:
from .queue.base import BaseStashQueue, QueueEntry
from .queue.celery import LocalCeleryQueue
from .queue.tempdir import TempDirQueue
from .queue.workspace import WorkspaceQueue
from .stash import ExpStashEntry
logger = logging.getLogger(__name__)
@@ -65,6 +63,11 @@ class Experiments:
@property
def scm(self):
from dvc.scm import SCMError
if self.repo.scm.no_commits:
raise SCMError("Empty Git repo. Add a commit to use experiments.")
return self.repo.scm
@cached_property
@@ -73,20 +76,28 @@ class Experiments:
@cached_property
def args_file(self) -> str:
from .executor.base import BaseExecutor
return os.path.join(self.repo.tmp_dir, BaseExecutor.PACKED_ARGS_FILE)
@cached_property
def workspace_queue(self) -> WorkspaceQueue:
def workspace_queue(self) -> "WorkspaceQueue":
from .queue.workspace import WorkspaceQueue
return WorkspaceQueue(self.repo, WORKSPACE_STASH)
@cached_property
def tempdir_queue(self) -> TempDirQueue:
def tempdir_queue(self) -> "TempDirQueue":
from .queue.tempdir import 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:
def celery_queue(self) -> "LocalCeleryQueue":
from .queue.celery import LocalCeleryQueue
return LocalCeleryQueue(self.repo, CELERY_STASH, CELERY_FAILED_STASH)
@cached_property
@@ -107,6 +118,7 @@ class Experiments:
def reproduce_one(
self,
tmp_dir: bool = False,
copy_paths: Optional[List[str]] = None,
**kwargs,
):
"""Reproduce and checkout a single (standalone) experiment."""
@@ -114,7 +126,7 @@ class Experiments:
self.tempdir_queue if tmp_dir else self.workspace_queue
)
self.queue_one(exp_queue, **kwargs)
results = self._reproduce_queue(exp_queue)
results = self._reproduce_queue(exp_queue, copy_paths=copy_paths)
exp_rev = first(results)
if exp_rev is not None:
self._log_reproduced(results, tmp_dir=tmp_dir)
@@ -193,8 +205,7 @@ class Experiments:
)
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.wait_for_start(entry, sleep_interval=1)
self.celery_queue.follow(entry)
# wait for task collection to complete
try:
@@ -337,7 +348,9 @@ class Experiments:
self.scm.remove_ref(EXEC_APPLY)
@unlocked_repo
def _reproduce_queue(self, queue: "BaseStashQueue", **kwargs) -> Dict[str, str]:
def _reproduce_queue(
self, queue: "BaseStashQueue", copy_paths: Optional[List[str]] = None, **kwargs
) -> Dict[str, str]:
"""Reproduce queued experiments.
Arguments:
@@ -347,7 +360,7 @@ class Experiments:
dict mapping successfully reproduced experiment revs to their
results.
"""
exec_results = queue.reproduce()
exec_results = queue.reproduce(copy_paths=copy_paths)
results: Dict[str, str] = {}
for _, exp_result in exec_results.items():
+3 -1
View File
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
@locked
@scm_context
def branch(repo, exp_rev, branch_name, *args, **kwargs):
def branch(repo, exp_rev, branch_name=None, **kwargs):
from dvc.scm import resolve_rev
try:
@@ -46,6 +46,8 @@ def branch(repo, exp_rev, branch_name, *args, **kwargs):
if not ref_info:
raise InvalidExpRevError(exp_rev)
branch_name = branch_name or f"{ref_info.name}-branch"
branch_ref = f"refs/heads/{branch_name}"
if repo.scm.get_ref(branch_ref):
raise InvalidArgumentError(f"Git branch '{branch_name}' already exists.")
+380
View File
@@ -0,0 +1,380 @@
import itertools
import logging
import os
from datetime import datetime
from typing import (
TYPE_CHECKING,
Collection,
Dict,
Iterable,
Iterator,
List,
Optional,
Union,
)
from funcy import first
from scmrepo.exceptions import SCMError as InnerSCMError
from dvc.scm import Git, SCMError, iter_revs
from .exceptions import InvalidExpRefError
from .refs import EXEC_BRANCH, ExpRefInfo
from .serialize import ExpRange, ExpState, SerializableError, SerializableExp
if TYPE_CHECKING:
from dvc.repo import Repo
from .cache import ExpCache
logger = logging.getLogger(__name__)
def collect_rev(
repo: "Repo",
rev: str,
param_deps: bool = False,
force: bool = False,
cache: Optional["ExpCache"] = None,
**kwargs,
) -> ExpState:
"""Collect experiment state for the given revision.
Exp will be loaded from cache when available unless rev is 'workspace' or
force is set.
"""
from dvc.fs import LocalFileSystem
cache = cache or repo.experiments.cache
assert cache
# TODO: support filtering serialized exp when param_deps is set
if rev != "workspace" and not (force or param_deps):
cached_exp = cache.get(rev)
if cached_exp:
if isinstance(cached_exp, SerializableError):
return ExpState(rev=rev, error=cached_exp)
return ExpState(rev=rev, data=cached_exp)
if rev == "workspace" and isinstance(repo.fs, LocalFileSystem):
orig_cwd: Optional[str] = os.getcwd()
os.chdir(repo.root_dir)
else:
orig_cwd = None
try:
data = _collect_rev(
repo,
rev,
param_deps=param_deps,
force=force,
**kwargs,
)
if not (rev == "workspace" or param_deps or data.contains_error):
cache.put(data, force=True)
return ExpState(rev=rev, data=data)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
logger.debug("", exc_info=True)
error = SerializableError(str(exc), type(exc).__name__)
return ExpState(rev=rev, error=error)
finally:
if orig_cwd:
os.chdir(orig_cwd)
def _collect_rev(
repo: "Repo",
rev: str,
param_deps: bool = False,
**kwargs,
) -> SerializableExp:
with repo.switch(rev) as rev:
if rev == "workspace":
timestamp: Optional[datetime] = None
else:
commit = repo.scm.resolve_commit(rev)
timestamp = datetime.fromtimestamp(commit.commit_time)
return SerializableExp.from_repo(
repo,
rev=rev,
param_deps=param_deps,
timestamp=timestamp,
)
def collect_branch(
repo: "Repo",
rev: str,
end_rev: Optional[str] = None,
**kwargs,
) -> Iterator["ExpState"]:
"""Iterate over exp states in a Git branch.
Git branch will be traversed in reverse, starting from rev.
Args:
rev: Branch tip (head).
end_rev: If specified, traversal will stop when end_rev is reached
(exclusive, end_rev will not be collected).
"""
try:
for branch_rev in repo.scm.branch_revs(rev, end_rev):
yield collect_rev(repo, branch_rev, **kwargs)
except (SCMError, InnerSCMError):
pass
def collect_exec_branch(
repo: "Repo",
baseline_rev: str,
**kwargs,
) -> Iterator["ExpState"]:
"""Iterate over active experiment branch for the current executor."""
last_rev = repo.scm.get_ref(EXEC_BRANCH) or repo.scm.get_rev()
last_rev = repo.scm.get_rev()
yield collect_rev(repo, "workspace", **kwargs)
if last_rev != baseline_rev:
yield from collect_branch(repo, last_rev, baseline_rev, **kwargs)
def collect_queued(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect queued experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to list of queued experiments.
"""
if not baseline_revs:
return {}
return repo.experiments.celery_queue.collect_queued_data(baseline_revs, **kwargs)
def collect_active(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect active (running) experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to list of active experiments.
"""
if not baseline_revs:
return {}
result: Dict[str, List["ExpRange"]] = {}
exps = repo.experiments
for queue in (exps.workspace_queue, exps.tempdir_queue, exps.celery_queue):
for baseline, active_exps in queue.collect_active_data(
baseline_revs, **kwargs
).items():
if baseline in result:
result[baseline].extend(active_exps)
else:
result[baseline] = list(active_exps)
return result
def collect_failed(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect failed experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to list of active experiments.
"""
if not baseline_revs:
return {}
return repo.experiments.celery_queue.collect_failed_data(baseline_revs, **kwargs)
def collect_successful(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect successful experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to successful experiments.
"""
result: Dict[str, List["ExpRange"]] = {}
for baseline_rev in baseline_revs:
result[baseline_rev] = list(_collect_baseline(repo, baseline_rev, **kwargs))
return result
def _collect_baseline(
repo: "Repo",
baseline_rev: str,
**kwargs,
) -> Iterator["ExpRange"]:
"""Iterate over experiments derived from a baseline revision.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Yields:
Tuple of (timestamp, exp_range).
"""
ref_info = ExpRefInfo(baseline_sha=baseline_rev)
refs: Optional[Iterable[str]] = kwargs.get("refs")
if refs:
ref_it = (ref for ref in iter(refs) if ref.startswith(str(ref_info)))
else:
ref_it = repo.scm.iter_refs(base=str(ref_info))
for ref in ref_it:
try:
ref_info = ExpRefInfo.from_ref(ref)
exp_rev = repo.scm.get_ref(ref)
if not exp_rev:
continue
except (InvalidExpRefError, SCMError, InnerSCMError):
continue
exps = list(collect_branch(repo, exp_rev, baseline_rev, **kwargs))
if exps:
exps[0].name = ref_info.name
yield ExpRange(exps, name=ref_info.name)
def collect(
repo: "Repo",
revs: Union[List[str], str, None] = None,
all_branches: bool = False,
all_tags: bool = False,
all_commits: bool = False,
num: int = 1,
hide_queued: bool = False,
hide_failed: bool = False,
sha_only: bool = False,
**kwargs,
) -> List["ExpState"]:
"""Collect baseline revisions and derived experiments."""
assert isinstance(repo.scm, Git)
if repo.scm.no_commits:
return []
if not any([revs, all_branches, all_tags, all_commits]):
revs = ["HEAD"]
if isinstance(revs, str):
revs = [revs]
cached_refs = list(repo.scm.iter_refs())
baseline_revs = list(
iter_revs(
repo.scm,
revs=revs,
num=num,
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
)
)
if sha_only:
baseline_names: Dict[str, Optional[str]] = {}
else:
baseline_names = _describe(repo.scm, baseline_revs, refs=cached_refs)
workspace_data = collect_rev(repo, "workspace", **kwargs)
result: List["ExpState"] = [workspace_data]
queued = collect_queued(repo, baseline_revs, **kwargs) if not hide_queued else {}
active = collect_active(repo, baseline_revs, **kwargs)
failed = collect_failed(repo, baseline_revs, **kwargs) if not hide_failed else {}
successful = collect_successful(repo, baseline_revs, **kwargs)
for baseline_rev in baseline_revs:
baseline_data = collect_rev(repo, baseline_rev)
experiments = list(
itertools.chain.from_iterable(
_sorted_ranges(collected.get(baseline_rev, []))
for collected in (successful, active, queued, failed)
)
)
result.append(
ExpState(
rev=baseline_rev,
name=baseline_names.get(baseline_rev),
data=baseline_data.data,
error=baseline_data.error,
experiments=experiments if experiments else None,
)
)
return result
def _describe(
scm: "Git",
revs: Iterable[str],
refs: Optional[Iterable[str]] = None,
) -> Dict[str, Optional[str]]:
"""Describe revisions using a tag, branch.
The first matching name will be returned for each rev. Names are preferred in this
order:
- current branch (if rev matches HEAD and HEAD is a branch)
- tags
- branches
Returns:
Dict mapping revisions from revs to a name.
"""
head_rev = scm.get_rev()
head_ref = scm.get_ref("HEAD", follow=False)
if head_ref and head_ref.startswith("refs/heads/"):
head_branch = head_ref[len("refs/heads/") :]
else:
head_branch = None
tags = {}
branches = {}
ref_it = iter(refs) if refs else scm.iter_refs()
for ref in ref_it:
is_tag = ref.startswith("refs/tags/")
is_branch = ref.startswith("refs/heads/")
if not (is_tag or is_branch):
continue
rev = scm.get_ref(ref)
if not rev:
logger.debug("unresolved ref %s", ref)
continue
if is_tag and rev not in tags:
tags[rev] = ref[len("refs/tags/") :]
if is_branch and rev not in branches:
branches[rev] = ref[len("refs/heads/") :]
names: Dict[str, Optional[str]] = {}
for rev in revs:
if rev == head_rev and head_branch:
names[rev] = head_branch
else:
names[rev] = tags.get(rev) or branches.get(rev)
return names
def _sorted_ranges(exp_ranges: Iterable["ExpRange"]) -> List["ExpRange"]:
"""Return list of ExpRange sorted by timestamp."""
def _head_timestamp(exp_range: "ExpRange") -> datetime:
head_exp = first(exp_range.revs)
if head_exp and head_exp.data and head_exp.data.timestamp:
return head_exp.data.timestamp
return datetime.fromtimestamp(0)
return sorted(exp_ranges, key=_head_timestamp, reverse=True)
+7 -8
View File
@@ -7,7 +7,7 @@ logger = logging.getLogger(__name__)
def diff(repo, *args, a_rev=None, b_rev=None, param_deps=False, **kwargs):
from dvc.repo.experiments.show import collect_experiment_commit
from dvc.repo.experiments.collect import collect_rev
from dvc.scm import resolve_rev
if repo.scm.no_commits:
@@ -15,22 +15,21 @@ def diff(repo, *args, a_rev=None, b_rev=None, param_deps=False, **kwargs):
if a_rev:
rev = resolve_rev(repo.scm, a_rev)
old = collect_experiment_commit(repo, rev, param_deps=param_deps)
else:
old = collect_experiment_commit(repo, "HEAD", param_deps=param_deps)
rev = resolve_rev(repo.scm, "HEAD")
old = collect_rev(repo, rev, param_deps=param_deps)
if b_rev:
rev = resolve_rev(repo.scm, b_rev)
new = collect_experiment_commit(repo, rev, param_deps=param_deps)
else:
new = collect_experiment_commit(repo, "workspace", param_deps=param_deps)
rev = "workspace"
new = collect_rev(repo, rev, param_deps=param_deps)
with_unchanged = kwargs.pop("all", False)
return {
key: _diff(
format_dict(old.get("data", {}).get(key, {})),
format_dict(new.get("data", {}).get(key, {})),
format_dict(getattr(old.data, key, {})),
format_dict(getattr(new.data, key, {})),
with_unchanged=with_unchanged,
)
for key in ["metrics", "params"]
+106 -5
View File
@@ -1,6 +1,7 @@
import logging
import os
import pickle
import pickle # nosec B403
import shutil
from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import asdict, dataclass
@@ -12,6 +13,7 @@ from typing import (
Callable,
Dict,
Iterable,
Iterator,
List,
NamedTuple,
Optional,
@@ -34,6 +36,8 @@ from dvc.repo.experiments.refs import (
ExpRefInfo,
)
from dvc.repo.experiments.utils import to_studio_params
from dvc.repo.metrics.show import _collect_top_level_metrics
from dvc.repo.params.show import _collect_top_level_params
from dvc.stage.serialize import to_lockfile
from dvc.ui import ui
from dvc.utils import dict_sha256, env2bool, relpath
@@ -256,6 +260,74 @@ class BaseExecutor(ABC):
**kwargs,
)
@classmethod
def _get_top_level_paths(cls, repo: "Repo") -> Iterable["str"]:
yield from _collect_top_level_metrics(repo)
yield from _collect_top_level_params(repo)
yield from repo.index._plot_sources # pylint: disable=protected-access
@classmethod
def save(
cls,
info: "ExecutorInfo",
force: bool = False,
include_untracked: Optional[List[str]] = None,
) -> ExecutorResult:
from dvc.dvcfile import LOCK_FILE
from dvc.repo import Repo
exp_hash: Optional[str] = None
exp_ref: Optional[ExpRefInfo] = None
dvc = Repo(os.path.join(info.root_dir, info.dvc_dir))
old_cwd = os.getcwd()
if info.wdir:
os.chdir(os.path.join(dvc.scm.root_dir, info.wdir))
else:
os.chdir(dvc.root_dir)
include_untracked = include_untracked or []
include_untracked.extend(cls._get_top_level_paths(dvc))
# dvc repro automatically stages dvc.lock. Running redundant `git add`
# on it causes an error when exiting the detached head context.
if LOCK_FILE in dvc.scm.untracked_files():
include_untracked.append(LOCK_FILE)
try:
stages = dvc.commit([], force=True, relink=False)
exp_hash = cls.hash_exp(stages)
if include_untracked:
dvc.scm.add(include_untracked)
cls.commit(
dvc.scm, # type: ignore[arg-type]
exp_hash,
exp_name=info.name,
force=force,
)
ref: Optional[str] = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
exp_ref = ExpRefInfo.from_ref(ref) if ref else None
untracked = dvc.scm.untracked_files()
if untracked:
logger.warning(
"The following untracked files were present in "
"the workspace before saving but "
"will not be included in the experiment commit:\n"
"\t%s",
", ".join(untracked),
)
info.result_hash = exp_hash
info.result_ref = ref
info.result_force = False
info.status = TaskStatus.SUCCESS
except DvcException:
info.status = TaskStatus.FAILED
raise
finally:
dvc.close()
os.chdir(old_cwd)
return ExecutorResult(ref, exp_ref, info.result_force)
@staticmethod
def hash_exp(stages: Iterable["PipelineStage"]) -> str:
from dvc.stage import PipelineStage
@@ -266,7 +338,7 @@ class BaseExecutor(ABC):
exp_data.update(to_lockfile(stage))
return dict_sha256(exp_data)
def cleanup(self, infofile: str):
def cleanup(self, infofile: Optional[str] = None):
if infofile is not None:
info = ExecutorInfo.load_json(infofile)
if info.status < TaskStatus.FAILED:
@@ -293,7 +365,7 @@ class BaseExecutor(ABC):
@staticmethod
def unpack_repro_args(path):
with open(path, "rb") as fobj:
data = pickle.load(fobj)
data = pickle.load(fobj) # noqa: S301 # nosec B301
return data["args"], data["kwargs"]
def fetch_exps(
@@ -383,6 +455,7 @@ class BaseExecutor(ABC):
infofile: Optional[str] = None,
log_errors: bool = True,
log_level: Optional[int] = None,
copy_paths: Optional[List[str]] = None,
**kwargs,
) -> "ExecutorResult":
"""Run dvc repro and return the result.
@@ -419,6 +492,7 @@ class BaseExecutor(ABC):
info,
infofile,
log_errors=log_errors,
copy_paths=copy_paths,
**kwargs,
) as dvc:
if auto_push:
@@ -469,11 +543,19 @@ class BaseExecutor(ABC):
info.name,
repro_force or checkpoint_reset,
)
def after_repro():
paths = list(cls._get_top_level_paths(dvc))
if paths:
logger.debug("Staging top-level files: %s", paths)
dvc.scm_context.add(paths)
stages = dvc_reproduce(
dvc,
*args,
on_unchanged=filter_pipeline,
checkpoint_func=checkpoint_func,
after_repro_callback=after_repro,
**kwargs,
)
@@ -541,8 +623,9 @@ class BaseExecutor(ABC):
info: "ExecutorInfo",
infofile: Optional[str] = None,
log_errors: bool = True,
copy_paths: Optional[List[str]] = None,
**kwargs,
):
) -> Iterator["Repo"]:
from dvc_studio_client.post_live_metrics import post_live_metrics
from dvc.repo import Repo
@@ -555,6 +638,10 @@ class BaseExecutor(ABC):
if cls.QUIET:
dvc.scm_context.quiet = cls.QUIET
old_cwd = os.getcwd()
for path in copy_paths or []:
cls._copy_path(os.path.realpath(path), os.path.join(dvc.root_dir, path))
if info.wdir:
os.chdir(os.path.join(dvc.scm.root_dir, info.wdir))
else:
@@ -605,7 +692,7 @@ class BaseExecutor(ABC):
args, kwargs = cls.unpack_repro_args(args_path)
remove(args_path)
# explicitly git rm/unstage the args file
dvc.scm.add([args_path])
dvc.scm.add([args_path], force=True)
else:
args = []
kwargs = {}
@@ -724,6 +811,20 @@ class BaseExecutor(ABC):
if level is not None:
dvc_logger.setLevel(level)
@staticmethod
def _copy_path(src, dst):
try:
if os.path.isfile(src):
shutil.copy(src, dst)
elif os.path.isdir(src):
shutil.copytree(src, dst)
else:
raise DvcException(
f"Unable to copy '{src}'. It is not a file or directory."
)
except OSError as exc:
raise DvcException(f"Unable to copy '{src}' to '{dst}'.") from exc
@contextmanager
def set_temp_refs(self, scm: "Git", temp_dict: Dict[str, str]):
try:
+17 -8
View File
@@ -4,6 +4,7 @@ from contextlib import ExitStack
from tempfile import mkdtemp
from typing import TYPE_CHECKING, Optional, Union
from configobj import ConfigObj
from funcy import retry
from shortuuid import uuid
@@ -48,7 +49,7 @@ class BaseLocalExecutor(BaseExecutor):
def scm(self) -> Union["Git", "NoSCM"]:
return SCM(self.root_dir)
def cleanup(self, infofile: str):
def cleanup(self, infofile: Optional[str] = None):
self.scm.close()
del self.scm
super().cleanup(infofile)
@@ -134,24 +135,31 @@ class TempDirExecutor(BaseLocalExecutor):
merge_rev = self.scm.get_ref(EXEC_MERGE)
self.scm.stash.apply(merge_rev)
self._update_config(repo.config.read("local"))
def _config(self, cache_dir):
def _update_config(self, update):
local_config = os.path.join(
self.root_dir,
self.dvc_dir,
"config.local",
)
logger.debug("Writing experiments local config '%s'", local_config)
with open(local_config, "w", encoding="utf-8") as fobj:
fobj.write(f"[cache]\n dir = {cache_dir}")
if os.path.exists(local_config):
conf_obj = ConfigObj(local_config)
conf_obj.merge(update)
else:
conf_obj = ConfigObj(update)
if conf_obj:
with open(local_config, "wb") as fobj:
conf_obj.write(fobj)
def init_cache(
self, repo: "Repo", rev: str, run_cache: bool = True # noqa: ARG002
):
"""Initialize DVC cache."""
self._config(repo.cache.repo.path)
self._update_config({"cache": {"dir": repo.cache.repo.path}})
def cleanup(self, infofile: str):
def cleanup(self, infofile: Optional[str] = None):
super().cleanup(infofile)
logger.debug("Removing tmpdir '%s'", self.root_dir)
remove(self.root_dir)
@@ -234,9 +242,10 @@ class WorkspaceExecutor(BaseLocalExecutor):
def init_cache(self, repo: "Repo", rev: str, run_cache: bool = True):
pass
def cleanup(self, infofile: str):
def cleanup(self, infofile: Optional[str] = None):
super().cleanup(infofile)
remove(os.path.dirname(infofile))
if infofile:
remove(os.path.dirname(infofile))
with self._detach_stack:
self.scm.remove_ref(EXEC_BASELINE)
self.scm.remove_ref(EXEC_MERGE)
+2 -1
View File
@@ -3,7 +3,7 @@ import os
import posixpath
import sys
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, Iterable, Optional
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional
from dvc_ssh import SSHFileSystem
from funcy import first
@@ -242,6 +242,7 @@ class SSHExecutor(BaseExecutor):
infofile: Optional[str] = None,
log_errors: bool = True,
log_level: Optional[int] = None,
copy_paths: Optional[List[str]] = None, # noqa: ARG003
**kwargs,
) -> "ExecutorResult":
"""Reproduce an experiment on a remote machine over SSH.
+87 -29
View File
@@ -1,35 +1,86 @@
import logging
from typing import Iterable, List, Mapping, Optional, Set, Union
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
List,
Mapping,
Optional,
Set,
Union,
)
from funcy import group_by
from funcy import compact, group_by
from scmrepo.git.backend.base import SyncStatus
from dvc.env import DVC_STUDIO_TOKEN, DVC_STUDIO_URL
from dvc.exceptions import DvcException
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm import TqdmGit, iter_revs
from dvc.ui import ui
from dvc.scm import Git, TqdmGit, iter_revs
from dvc.utils import env2bool
from .exceptions import UnresolvedExpNamesError
from .refs import ExpRefInfo
from .utils import exp_commits, exp_refs, exp_refs_by_baseline, resolve_name
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
class UploadError(DvcException):
def __init__(self, msg, result):
self.result = result
super().__init__(msg)
def notify_refs_to_studio(
repo: "Repo", git_remote: str, **refs: List[str]
) -> Optional[str]:
import os
config = repo.config["studio"]
refs = compact(refs)
if not refs or env2bool("DVC_TEST"):
return None
token = (
os.environ.get(DVC_STUDIO_TOKEN)
or os.environ.get("STUDIO_TOKEN")
or config.get("token")
)
if not token:
logger.debug("Studio token not found.")
return None
from dulwich.porcelain import get_remote_repo
from dvc.utils import studio
_, repo_url = get_remote_repo(repo.scm.dulwich.repo, git_remote)
studio_url = os.environ.get(DVC_STUDIO_URL) or config.get("url")
d = studio.notify_refs(repo_url, token, base_url=studio_url, **refs)
return d.get("url")
@locked
@scm_context
def push( # noqa: C901
repo,
repo: "Repo",
git_remote: str,
exp_names: Union[Iterable[str], str],
all_commits=False,
all_commits: bool = False,
rev: Optional[str] = None,
num=1,
num: int = 1,
force: bool = False,
push_cache: bool = False,
**kwargs,
) -> Iterable[str]:
**kwargs: Any,
) -> Dict[str, Any]:
exp_ref_set: Set["ExpRefInfo"] = set()
assert isinstance(repo.scm, Git)
if all_commits:
exp_ref_set.update(exp_refs(repo.scm))
@@ -57,23 +108,29 @@ def push( # noqa: C901
exp_ref_set.update(ref_info_list)
push_result = _push(repo, git_remote, exp_ref_set, force)
if push_result[SyncStatus.DIVERGED]:
diverged_refs = [ref.name for ref in push_result[SyncStatus.DIVERGED]]
ui.warn(
f"Local experiment '{diverged_refs}' has diverged from remote "
"experiment with the same name. To override the remote experiment "
"re-run with '--force'."
)
refs = {
status.name.lower(): [ref.name for ref in ref_list]
for status, ref_list in push_result.items()
}
result: Dict[str, Any] = {**refs, "uploaded": 0}
pushed_refs_info = (
push_result[SyncStatus.UP_TO_DATE] + push_result[SyncStatus.SUCCESS]
)
if push_cache:
push_cache_ref = (
push_result[SyncStatus.UP_TO_DATE] + push_result[SyncStatus.SUCCESS]
)
_push_cache(repo, push_cache_ref, **kwargs)
return [ref.name for ref in push_result[SyncStatus.SUCCESS]]
try:
result["uploaded"] = _push_cache(repo, pushed_refs_info, **kwargs)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
raise UploadError("failed to push cache", result) from exc
pushed_refs = [str(r) for r in pushed_refs_info]
url = notify_refs_to_studio(repo, git_remote, pushed=pushed_refs)
return {**result, "url": url}
def _push(
repo,
repo: "Repo",
git_remote: str,
refs: Iterable["ExpRefInfo"],
force: bool,
@@ -83,7 +140,7 @@ def _push(
from dvc.scm import GitAuthError
refspec_list = [f"{exp_ref}:{exp_ref}" for exp_ref in refs]
logger.debug("git push experiment '%s' -> '%s'", refspec_list, git_remote)
logger.debug("git push experiment %s -> '%s'", refspec_list, git_remote)
with TqdmGit(desc="Pushing git refs") as pbar:
try:
@@ -105,14 +162,15 @@ def _push(
def _push_cache(
repo,
repo: "Repo",
refs: Union[ExpRefInfo, Iterable["ExpRefInfo"]],
dvc_remote=None,
jobs=None,
run_cache=False,
):
dvc_remote: Optional[str] = None,
jobs: Optional[int] = None,
run_cache: bool = False,
) -> int:
if isinstance(refs, ExpRefInfo):
refs = [refs]
assert isinstance(repo.scm, Git)
revs = list(exp_commits(repo.scm, refs))
logger.debug("dvc push experiment '%s'", refs)
repo.push(jobs=jobs, remote=dvc_remote, run_cache=run_cache, revs=revs)
return repo.push(jobs=jobs, remote=dvc_remote, run_cache=run_cache, revs=revs)
+67 -4
View File
@@ -17,6 +17,8 @@ from typing import (
Union,
)
from dvc_studio_client.env import STUDIO_REPO_URL
from dvc_studio_client.post_live_metrics import get_studio_repo_url
from funcy import retry
from dvc.dependency import ParamsDependency
@@ -44,6 +46,7 @@ if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.repo.experiments import Experiments
from dvc.repo.experiments.executor.base import ExecutorResult
from dvc.repo.experiments.serialize import ExpRange
from dvc.scm import Git
logger = logging.getLogger(__name__)
@@ -250,7 +253,9 @@ class BaseStashQueue(ABC):
"""Iterate over items which been failed."""
@abstractmethod
def reproduce(self) -> Mapping[str, Mapping[str, str]]:
def reproduce(
self, copy_paths: Optional[List[str]] = None
) -> Mapping[str, Mapping[str, str]]:
"""Reproduce queued experiments sequentially."""
@abstractmethod
@@ -415,6 +420,10 @@ class BaseStashQueue(ABC):
run_env[DVC_EXP_NAME] = name
if resume_rev:
run_env[DVCLIVE_RESUME] = "1"
studio_repo_url = get_studio_repo_url()
if studio_repo_url is not None:
run_env[STUDIO_REPO_URL] = studio_repo_url
self._pack_args(*args, run_env=run_env, **kwargs)
# save experiment as a stash commit
@@ -490,7 +499,7 @@ class BaseStashQueue(ABC):
return msg
def _pack_args(self, *args, **kwargs) -> None:
import pickle
import pickle # nosec B403
if os.path.exists(self.args_file) and self.scm.is_tracked(self.args_file):
logger.warning(
@@ -505,14 +514,14 @@ class BaseStashQueue(ABC):
)
with open(self.args_file, "rb") as fobj:
try:
data = pickle.load(fobj)
data = pickle.load(fobj) # noqa: S301 # nosec B301
except Exception: # noqa: BLE001, 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)
self.scm.add(self.args_file, force=True)
@staticmethod
def _format_new_params_msg(new_params, config_path):
@@ -726,3 +735,57 @@ class BaseStashQueue(ABC):
Args:
fetch_ref (bool): fetch completed checkpoints or not.
"""
@abstractmethod
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False,
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect data for active (running) experiments.
Args:
baseline_revs: Optional resolved baseline Git SHAs. If set, only experiments
derived from the specified revisions will be collected. Defaults to
collecting all experiments.
fetch_refs: Whether or not to fetch completed checkpoint commits from Git
remote.
Returns:
Dict mapping baseline revision to list of active experiments.
"""
@abstractmethod
def collect_queued_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect data for queued experiments.
Args:
baseline_revs: Optional resolved baseline Git SHAs. If set, only experiments
derived from the specified revisions will be collected. Defaults to
collecting all experiments.
Returns:
Dict mapping baseline revision to list of queued experiments.
"""
@abstractmethod
def collect_failed_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect data for failed experiments.
Args:
baseline_revs: Optional resolved baseline Git SHAs. If set, only experiments
derived from the specified revisions will be collected. Defaults to
collecting all experiments.
Returns:
Dict mapping baseline revision to list of queued experiments.
"""
+161 -6
View File
@@ -2,6 +2,7 @@ import hashlib
import locale
import logging
import os
from collections import defaultdict
from typing import (
TYPE_CHECKING,
Collection,
@@ -40,6 +41,7 @@ if TYPE_CHECKING:
from dvc.repo.experiments.executor.base import ExecutorResult
from dvc.repo.experiments.refs import ExpRefInfo
from dvc.repo.experiments.serialize import ExpRange
from dvc_task.app import FSApp
from dvc_task.proc.manager import ProcessManager
from dvc_task.worker import TemporaryWorker
@@ -174,11 +176,13 @@ class LocalCeleryQueue(BaseStashQueue):
return started
def put(self, *args, **kwargs) -> QueueEntry:
def put(
self, *args, copy_paths: Optional[List[str]] = None, **kwargs
) -> QueueEntry:
"""Stash an experiment and add it to the queue."""
with get_exp_rwlock(self.repo, writes=["workspace", CELERY_STASH]):
entry = self._stash_exp(*args, **kwargs)
self.celery.signature(run_exp.s(entry.asdict())).delay()
self.celery.signature(run_exp.s(entry.asdict(), copy_paths=copy_paths)).delay()
return entry
# NOTE: Queue consumption should not be done directly. Celery worker(s)
@@ -196,7 +200,9 @@ class LocalCeleryQueue(BaseStashQueue):
continue
args, kwargs, _embed = msg.decode()
entry_dict = kwargs.get("entry_dict", args[0])
logger.debug("Found queued task %s", entry_dict["stash_rev"])
logger.trace( # type: ignore[attr-defined]
"Found queued task %s", entry_dict["stash_rev"]
)
yield _MessageEntry(msg, QueueEntry.from_dict(entry_dict))
def _iter_processed(self) -> Generator[_MessageEntry, None, None]:
@@ -212,7 +218,9 @@ class LocalCeleryQueue(BaseStashQueue):
task_id = msg.headers["id"]
result: AsyncResult = AsyncResult(task_id)
if not result.ready():
logger.debug("Found active task %s", entry.stash_rev)
logger.trace( # type: ignore[attr-defined]
"Found active task %s", entry.stash_rev
)
yield _TaskEntry(result, entry)
def _iter_done_tasks(self) -> Generator[_TaskEntry, None, None]:
@@ -220,7 +228,9 @@ class LocalCeleryQueue(BaseStashQueue):
task_id = msg.headers["id"]
result: AsyncResult = AsyncResult(task_id)
if result.ready():
logger.debug("Found done task %s", entry.stash_rev)
logger.trace( # type: ignore[attr-defined]
"Found done task %s", entry.stash_rev
)
yield _TaskEntry(result, entry)
def iter_active(self) -> Generator[QueueEntry, None, None]:
@@ -250,7 +260,9 @@ class LocalCeleryQueue(BaseStashQueue):
if exp_result is None:
yield QueueDoneResult(queue_entry, exp_result)
def reproduce(self) -> Mapping[str, Mapping[str, str]]:
def reproduce(
self, copy_paths: Optional[List[str]] = None
) -> Mapping[str, Mapping[str, str]]:
raise NotImplementedError
def _load_info(self, rev: str) -> ExecutorInfo:
@@ -295,6 +307,28 @@ class LocalCeleryQueue(BaseStashQueue):
# out of the active task list, and needs to be loaded here.
return self._get_done_result(entry, timeout)
def wait(self, revs: Collection[str], **kwargs) -> None:
"""Block until the specified tasks have completed."""
revs = [revs] if isinstance(revs, str) else revs
results = self.match_queue_entry_by_name(
revs, self.iter_queued(), self.iter_done(), self.iter_failed()
)
for entry in results.values():
if not entry:
continue
self.wait_for_start(entry, **kwargs)
try:
self.get_result(entry)
except FileNotFoundError:
pass
def wait_for_start(self, entry: QueueEntry, sleep_interval: float = 0.001) -> None:
"""Block until the specified task has been started."""
import time
while not self.proc.get(entry.stash_rev):
time.sleep(sleep_interval)
def _get_running_task_ids(self) -> Set[str]:
running_task_ids: Set[str] = set()
active_workers = self.worker_status()
@@ -486,3 +520,124 @@ class LocalCeleryQueue(BaseStashQueue):
queue_entry = None if git_remote else queue_entry_match[exp_name]
results[exp_name] = ExpRefAndQueueEntry(exp_ref, queue_entry)
return results
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False,
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo import Repo
from dvc.repo.experiments.collect import collect_exec_branch
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry in self.iter_active():
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
if fetch_refs:
fetch_running_exp_from_temp_dir(self, entry.stash_rev, fetch_refs)
proc_info = self.proc.get(entry.stash_rev)
executor_info = self._load_info(entry.stash_rev)
if proc_info:
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
root=executor_info.root_dir,
log=proc_info.stdout,
pid=proc_info.pid,
task_id=entry.stash_rev,
)
else:
local_exec = None
dvc_root = os.path.join(executor_info.root_dir, executor_info.dvc_dir)
with Repo(dvc_root) as exec_repo:
kwargs["cache"] = self.repo.experiments.cache
exps = list(
collect_exec_branch(exec_repo, executor_info.baseline_rev, **kwargs)
)
exps[0].rev = entry.stash_rev
exps[0].name = entry.name
result[entry.baseline_rev].append(
ExpRange(
exps,
executor=ExpExecutor(
"running",
name=executor_info.location,
local=local_exec,
),
name=entry.name,
)
)
return result
def collect_queued_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo.experiments.collect import collect_rev
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry in self.iter_queued():
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
exp = collect_rev(self.repo, entry.stash_rev, **kwargs)
exp.name = entry.name
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
task_id=entry.stash_rev,
)
result[entry.baseline_rev].append(
ExpRange(
[exp],
executor=ExpExecutor("queued", name="dvc-task", local=local_exec),
name=entry.name,
)
)
return result
def collect_failed_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo.experiments.collect import collect_rev
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
SerializableError,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry, _ in self.iter_failed():
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
proc_info = self.proc.get(entry.stash_rev)
if proc_info:
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
log=proc_info.stdout,
pid=proc_info.pid,
returncode=proc_info.returncode,
task_id=entry.stash_rev,
)
else:
local_exec = None
exp = collect_rev(self.repo, entry.stash_rev, **kwargs)
exp.name = entry.name
exp.error = SerializableError("Experiment run failed")
result[entry.baseline_rev].append(
ExpRange(
[exp],
executor=ExpExecutor("failed", local=local_exec),
name=entry.name,
)
)
return result
+5 -2
View File
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Dict
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from celery import shared_task
from celery.utils.log import get_task_logger
@@ -91,7 +91,7 @@ def cleanup_exp(executor: TempDirExecutor, infofile: str) -> None:
@shared_task
def run_exp(entry_dict: Dict[str, Any]) -> None:
def run_exp(entry_dict: Dict[str, Any], copy_paths: Optional[List[str]] = None) -> None:
"""Run a full experiment.
Experiment subtasks are executed inline as one atomic operation.
@@ -108,6 +108,9 @@ def run_exp(entry_dict: Dict[str, Any]) -> None:
executor = setup_exp.s(entry_dict)()
try:
cmd = ["dvc", "exp", "exec-run", "--infofile", infofile]
if copy_paths:
for path in copy_paths:
cmd.extend(["--copy-paths", path])
proc_dict = queue.proc.run_signature(cmd, name=entry.stash_rev)()
collect_exp.s(proc_dict, entry_dict)()
finally:
+59 -2
View File
@@ -1,7 +1,7 @@
import logging
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, Generator, Optional
from typing import TYPE_CHECKING, Collection, Dict, Generator, List, Optional
from funcy import first
@@ -19,6 +19,7 @@ from .workspace import WorkspaceQueue
if TYPE_CHECKING:
from dvc.repo.experiments import Experiments
from dvc.repo.experiments.executor.base import BaseExecutor, ExecutorResult
from dvc.repo.experiments.serialize import ExpRange
from dvc_task.proc.manager import ProcessManager
logger = logging.getLogger(__name__)
@@ -92,7 +93,11 @@ class TempDirQueue(WorkspaceQueue):
)
def _reproduce_entry(
self, entry: QueueEntry, executor: "BaseExecutor"
self,
entry: QueueEntry,
executor: "BaseExecutor",
copy_paths: Optional[List[str]] = None,
**kwargs,
) -> Dict[str, Dict[str, str]]:
from dvc.stage.monitor import CheckpointKilledError
@@ -107,6 +112,7 @@ class TempDirQueue(WorkspaceQueue):
infofile=infofile,
log_level=logger.getEffectiveLevel(),
log_errors=True,
copy_paths=copy_paths,
)
if not exec_result.exp_hash:
raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'")
@@ -143,3 +149,54 @@ class TempDirQueue(WorkspaceQueue):
fetch_running_exp_from_temp_dir(self, entry.stash_rev, fetch_refs)
)
return result
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False,
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo import Repo
from dvc.repo.experiments.collect import collect_exec_branch
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry in self.iter_active():
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
if fetch_refs:
fetch_running_exp_from_temp_dir(self, entry.stash_rev, fetch_refs)
proc_info = self.proc.get(entry.stash_rev)
infofile = self.get_infofile_path(entry.stash_rev)
executor_info = ExecutorInfo.load_json(infofile)
if proc_info:
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
root=executor_info.root_dir,
log=proc_info.stdout,
pid=proc_info.pid,
)
else:
local_exec = None
dvc_root = os.path.join(executor_info.root_dir, executor_info.dvc_dir)
with Repo(dvc_root) as repo:
exps = list(
collect_exec_branch(repo, executor_info.baseline_rev, **kwargs)
)
exps[0].rev = entry.stash_rev
exps[0].name = entry.name
result[entry.baseline_rev].append(
ExpRange(
exps,
executor=ExpExecutor(
"running",
name=executor_info.location,
local=local_exec,
),
name=entry.name,
)
)
return result
+95 -65
View File
@@ -1,28 +1,27 @@
import json
import logging
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Collection, Dict, Generator, List, Optional
import psutil
from funcy import first
from voluptuous import Invalid
from dvc.exceptions import DvcException
from dvc.lock import make_lock
from dvc.repo.experiments.exceptions import ExpQueueEmptyError
from dvc.repo.experiments.executor.base import ExecutorInfo, TaskStatus
from dvc.repo.experiments.executor.local import WorkspaceExecutor
from dvc.repo.experiments.refs import EXEC_BRANCH, WORKSPACE_STASH
from dvc.repo.experiments.utils import get_exp_rwlock
from dvc.rwlock import RWLOCK_FILE, RWLOCK_LOCK, SCHEMA
from dvc.utils import relpath
from dvc.utils.fs import remove
from dvc.utils.serialize import load_json
from .base import BaseStashQueue, QueueEntry, QueueGetResult
if TYPE_CHECKING:
from dvc.repo.experiments import Experiments
from dvc.repo.experiments.executor.base import BaseExecutor, ExecutorResult
from dvc.repo.experiments.serialize import ExpRange
from .base import QueueDoneResult
@@ -33,6 +32,7 @@ class WorkspaceQueue(BaseStashQueue):
_EXEC_NAME: Optional[str] = "workspace"
def put(self, *args, **kwargs) -> QueueEntry:
kwargs.pop("copy_paths", None)
with get_exp_rwlock(self.repo, writes=["workspace", WORKSPACE_STASH]):
return self._stash_exp(*args, **kwargs)
@@ -81,23 +81,33 @@ class WorkspaceQueue(BaseStashQueue):
def iter_success(self) -> Generator["QueueDoneResult", None, None]:
raise NotImplementedError
def reproduce(self) -> Dict[str, Dict[str, str]]:
def reproduce(
self, copy_paths: Optional[List[str]] = None
) -> 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))
results.update(
self._reproduce_entry(entry, executor, copy_paths=copy_paths)
)
except ExpQueueEmptyError:
pass
return results
def _reproduce_entry(
self, entry: QueueEntry, executor: "BaseExecutor"
self, entry: QueueEntry, executor: "BaseExecutor", **kwargs
) -> Dict[str, Dict[str, str]]:
kwargs.pop("copy_paths", None)
from dvc.stage.monitor import CheckpointKilledError
from dvc_task.proc.process import ProcessInfo
results: Dict[str, Dict[str, str]] = defaultdict(dict)
exec_name = self._EXEC_NAME or entry.stash_rev
proc_info = ProcessInfo(os.getpid(), None, None, None, None)
proc_info_path = self._proc_info_path(exec_name)
os.makedirs(os.path.dirname(proc_info_path), exist_ok=True)
proc_info.dump(proc_info_path)
infofile = self.get_infofile_path(exec_name)
try:
rev = entry.stash_rev
@@ -123,8 +133,29 @@ class WorkspaceQueue(BaseStashQueue):
raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'") from exc
finally:
executor.cleanup(infofile)
remove(self._proc_info_path(exec_name))
return results
def _proc_info_path(self, name: str) -> str:
return os.path.join(self.pid_dir, name, f"{name}.json")
@property
def _active_pid(self) -> Optional[int]:
from dvc_task.proc.process import ProcessInfo
assert self._EXEC_NAME
name = self._EXEC_NAME
try:
proc_info = ProcessInfo.load(self._proc_info_path(name))
pid = proc_info.pid
if psutil.pid_exists(pid):
return pid
logger.debug("Workspace exec PID '%d' no longer exists, removing.", pid)
remove(self._proc_info_path(name))
except (FileNotFoundError, json.JSONDecodeError):
pass
return None
@staticmethod
def collect_executor( # pylint: disable=unused-argument
exp: "Experiments",
@@ -157,70 +188,13 @@ class WorkspaceQueue(BaseStashQueue):
):
raise NotImplementedError
def check_rwlock(
self,
hardlink: bool = False,
autocorrect: bool = False,
) -> bool:
"""Check and autocorrect the RWLock status for file paths.
Args:
hardlink (bool): use hardlink lock to guard rwlock file when on
edit.
autocorrect (bool): autocorrect corrupted rwlock file.
Return:
(bool): if the pid alive.
"""
path = self.repo.fs.path.join(self.repo.tmp_dir, RWLOCK_FILE)
rwlock_guard = make_lock(
self.repo.fs.path.join(self.repo.tmp_dir, RWLOCK_LOCK),
tmp_dir=self.repo.tmp_dir,
hardlink_lock=hardlink,
)
with rwlock_guard:
try:
with self.repo.fs.open(path, encoding="utf-8") as fobj:
lock: Dict[str, List[Dict]] = SCHEMA(json.load(fobj))
file_path = first(lock["read"])
if not file_path:
return False
lock_info = first(lock["read"][file_path])
pid = int(lock_info["pid"])
if psutil.pid_exists(pid):
return True
cmd = lock_info["cmd"]
logger.warning(
"Process '%s' with (Pid %s), in RWLock-file '%s' had been killed.",
cmd,
pid,
relpath(path),
)
except FileNotFoundError:
return False
except json.JSONDecodeError:
logger.warning(
"Unable to read RWLock-file '%s'. JSON structure is corrupted",
relpath(path),
)
except Invalid:
logger.warning("RWLock-file '%s' format error.", relpath(path))
if autocorrect:
logger.warning("Delete corrupted RWLock-file '%s'", relpath(path))
remove(path)
return False
def get_running_exps(
self,
fetch_refs: bool = True, # noqa: ARG002
) -> Dict[str, Dict]:
from dvc.utils.serialize import load_json
assert self._EXEC_NAME
result: Dict[str, Dict] = {}
if not self.check_rwlock(autocorrect=True):
if self._active_pid is None:
return result
infofile = self.get_infofile_path(self._EXEC_NAME)
@@ -241,3 +215,59 @@ class WorkspaceQueue(BaseStashQueue):
else:
result[self._EXEC_NAME] = info.asdict()
return result
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False, # noqa: ARG002
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo.experiments.collect import collect_exec_branch
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
pid = self._active_pid
if pid is None:
return result
assert self._EXEC_NAME
infofile = self.get_infofile_path(self._EXEC_NAME)
try:
info = ExecutorInfo.from_dict(load_json(infofile))
except OSError:
return result
if (
(not baseline_revs or info.baseline_rev in baseline_revs)
and info.status < TaskStatus.FAILED
and info.status != TaskStatus.SUCCESS
):
local_exec = LocalExpExecutor(root=info.root_dir, pid=pid)
exps = list(collect_exec_branch(self.repo, info.baseline_rev, **kwargs))
exps[0].name = info.name
result[info.baseline_rev] = [
ExpRange(
exps,
executor=ExpExecutor("running", name="workspace", local=local_exec),
name=info.name,
)
]
return result
def collect_queued_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
raise NotImplementedError
def collect_failed_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
raise NotImplementedError
+13 -10
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
@locked
@scm_context
def remove( # noqa: C901
def remove( # noqa: C901, PLR0912
repo: "Repo",
exp_names: Union[None, str, List[str]] = None,
rev: Optional[str] = None,
@@ -39,13 +39,6 @@ def remove( # noqa: C901
removed.extend(celery_queue.clear(queued=True))
assert isinstance(repo.scm, Git)
if all_commits:
removed.extend(
_remove_commited_exps(
repo.scm, list(exp_refs(repo.scm, git_remote)), git_remote
)
)
return removed
exp_ref_list: List["ExpRefInfo"] = []
queue_entry_list: List["QueueEntry"] = []
@@ -65,11 +58,14 @@ def remove( # noqa: C901
queue_entry_list.append(result.queue_entry)
if remained:
raise UnresolvedExpNamesError(remained)
raise UnresolvedExpNamesError(remained, git_remote=git_remote)
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())
elif all_commits:
exp_ref_list.extend(exp_refs(repo.scm, git_remote))
removed = [ref.name for ref in exp_ref_list]
if exp_ref_list:
_remove_commited_exps(repo.scm, exp_ref_list, git_remote)
@@ -79,15 +75,22 @@ def remove( # noqa: C901
remove_tasks(celery_queue, queue_entry_list)
if git_remote:
from .push import notify_refs_to_studio
removed_refs = [str(r) for r in exp_ref_list]
notify_refs_to_studio(repo, git_remote, removed=removed_refs)
return removed
def _resolve_exp_by_baseline(
repo,
repo: "Repo",
rev: str,
num: int,
git_remote: Optional[str] = None,
) -> Dict[str, "ExpRefInfo"]:
assert isinstance(repo.scm, Git)
commit_ref_dict: Dict[str, "ExpRefInfo"] = {}
rev_dict = iter_revs(repo.scm, [rev], num)
rev_set = set(rev_dict.keys())
+20 -2
View File
@@ -11,7 +11,7 @@ logger = logging.getLogger(__name__)
@locked
def run( # noqa: C901
def run( # noqa: C901, PLR0912
repo,
targets: Optional[Iterable[str]] = None,
params: Optional[Iterable[str]] = None,
@@ -19,6 +19,7 @@ def run( # noqa: C901
jobs: int = 1,
tmp_dir: bool = False,
queue: bool = False,
copy_paths: Optional[Iterable[str]] = None,
**kwargs,
) -> Dict[str, str]:
"""Reproduce the specified targets as an experiment.
@@ -36,6 +37,18 @@ def run( # noqa: C901
from dvc.utils.hydra import to_hydra_overrides
path_overrides = to_path_overrides(params)
if tmp_dir or queue:
untracked = repo.scm.untracked_files()
for path in path_overrides:
if path in untracked:
logger.debug(
"'%s' is currently untracked but will be modified by DVC. "
"Adding it to git.",
path,
)
repo.scm.add([path])
hydra_sweep = any(
x.is_sweep_override()
for param_file in path_overrides
@@ -57,7 +70,11 @@ def run( # noqa: C901
if not queue:
return repo.experiments.reproduce_one(
targets=targets, params=path_overrides, tmp_dir=tmp_dir, **kwargs
targets=targets,
params=path_overrides,
tmp_dir=tmp_dir,
copy_paths=copy_paths,
**kwargs,
)
if hydra_sweep:
@@ -78,6 +95,7 @@ def run( # noqa: C901
repo.experiments.celery_queue,
targets=targets,
params=sweep_overrides,
copy_paths=copy_paths,
**kwargs,
)
if sweep_overrides:
+11 -62
View File
@@ -2,13 +2,7 @@ import logging
import os
from typing import TYPE_CHECKING, List, Optional
from pathspec import PathSpec
from dvc.scm import Git
from .exceptions import ExperimentExistsError
from .refs import ExpRefInfo
from .utils import check_ref_format, get_random_exp_name
from funcy import first
if TYPE_CHECKING:
from dvc.repo import Repo
@@ -17,34 +11,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _save_experiment(
repo: "Repo",
baseline_rev: str,
force: bool,
name: Optional[str],
include_untracked: Optional[List[str]],
) -> str:
repo.commit([], force=True, relink=False)
name = name or get_random_exp_name(repo.scm, baseline_rev)
ref_info = ExpRefInfo(baseline_rev, name)
check_ref_format(repo.scm.dulwich, ref_info)
ref = str(ref_info)
if repo.scm.get_ref(ref) and not force:
raise ExperimentExistsError(ref_info.name, command="save")
assert isinstance(repo.scm, Git)
repo.scm.add([], update=True)
if include_untracked:
repo.scm.add(include_untracked)
repo.scm.commit(f"dvc: commit experiment {name}", no_verify=True)
exp_rev = repo.scm.get_rev()
repo.scm.set_ref(ref, exp_rev, old_ref=None)
return exp_rev
def save(
repo: "Repo",
name: Optional[str] = None,
@@ -57,33 +23,16 @@ def save(
"""
logger.debug("Saving workspace in %s", os.getcwd())
assert isinstance(repo.scm, Git)
queue = repo.experiments.workspace_queue
entry = repo.experiments.new(queue=queue, name=name, force=force)
executor = queue.init_executor(repo.experiments, entry)
_, _, untracked = repo.scm.status()
if include_untracked:
spec = PathSpec.from_lines("gitwildmatch", include_untracked)
untracked = [file for file in untracked if not spec.match_file(file)]
if untracked:
logger.warning(
(
"The following untracked files were present in "
"the workspace before saving but "
"will not be included in the experiment commit:\n"
"\t%s"
),
", ".join(untracked),
try:
save_result = executor.save(
executor.info, force=force, include_untracked=include_untracked
)
result = queue.collect_executor(repo.experiments, executor, save_result)
finally:
executor.cleanup()
with repo.scm.detach_head(client="dvc") as orig_head:
with repo.scm.stash_workspace() as workspace:
try:
if workspace is not None:
repo.scm.stash.apply(workspace)
exp_rev = _save_experiment(
repo, orig_head, force, name, include_untracked
)
finally:
repo.scm.reset(hard=True)
return exp_rev
return first(result)
+81 -26
View File
@@ -1,7 +1,7 @@
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Literal, Optional
from dvc.exceptions import DvcException
from dvc.repo.metrics.show import _gather_metrics
@@ -25,17 +25,15 @@ class _ISOEncoder(json.JSONEncoder):
@dataclass(frozen=True)
class SerializableExp:
"""Serializable experiment state."""
"""Serializable experiment data."""
rev: str
timestamp: Optional[datetime] = None
params: Dict[str, Any] = field(default_factory=dict)
metrics: Dict[str, Any] = field(default_factory=dict)
deps: Dict[str, "_ExpDep"] = field(default_factory=dict)
outs: Dict[str, "_ExpOut"] = field(default_factory=dict)
status: Optional[str] = None
executor: Optional[str] = None
error: Optional["SerializableError"] = None
deps: Dict[str, "ExpDep"] = field(default_factory=dict)
outs: Dict[str, "ExpOut"] = field(default_factory=dict)
meta: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_repo(
@@ -43,6 +41,7 @@ class SerializableExp:
repo: "Repo",
rev: Optional[str] = None,
onerror: Optional[Callable] = None,
param_deps: bool = False,
**kwargs,
) -> "SerializableExp":
"""Returns a SerializableExp from the current repo state.
@@ -57,29 +56,26 @@ class SerializableExp:
rev = rev or repo.get_rev()
assert rev
status: Optional[str] = kwargs.get("status")
# NOTE: _gather_params/_gather_metrics return defaultdict which is not
# supported in dataclasses.asdict() on all python releases
# see https://bugs.python.org/issue35540
params = dict(_gather_params(repo, onerror=onerror))
if status and status.lower() in ("queued", "failed"):
metrics: Dict[str, Any] = {}
else:
metrics = dict(
_gather_metrics(
repo,
targets=None,
rev=rev[:7],
recursive=False,
onerror=onerror_collect,
)
params = dict(_gather_params(repo, deps=param_deps, onerror=onerror))
metrics = dict(
_gather_metrics(
repo,
targets=None,
rev=rev[:7],
recursive=False,
onerror=onerror_collect,
)
)
meta = cls._gather_meta(repo)
return cls(
rev=rev,
params=params,
metrics=metrics,
deps={
relpath(dep.fs_path, repo.root_dir): _ExpDep(
relpath(dep.fs_path, repo.root_dir): ExpDep(
hash=dep.hash_info.value if dep.hash_info else None,
size=dep.meta.size if dep.meta else None,
nfiles=dep.meta.nfiles if dep.meta else None,
@@ -88,7 +84,7 @@ class SerializableExp:
if not isinstance(dep, (ParamsDependency, RepoDependency))
},
outs={
relpath(out.fs_path, repo.root_dir): _ExpOut(
relpath(out.fs_path, repo.root_dir): ExpOut(
hash=out.hash_info.value if out.hash_info else None,
size=out.meta.size if out.meta else None,
nfiles=out.meta.nfiles if out.meta else None,
@@ -98,9 +94,16 @@ class SerializableExp:
for out in repo.index.outs
if not (out.is_metric or out.is_plot)
},
meta=meta,
**kwargs,
)
@staticmethod
def _gather_meta(repo: "Repo") -> Dict[str, Any]:
return {
"has_checkpoints": any(stage.is_checkpoint for stage in repo.index.stages)
}
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
@@ -113,6 +116,10 @@ class SerializableExp:
parsed = json.loads(data)
if "timestamp" in parsed:
parsed["timestamp"] = datetime.fromisoformat(parsed["timestamp"])
if "deps" in parsed:
parsed["deps"] = {k: ExpDep(**v) for k, v in parsed["deps"].items()}
if "outs" in parsed:
parsed["outs"] = {k: ExpOut(**v) for k, v in parsed["outs"].items()}
return cls(**parsed)
except (TypeError, json.JSONDecodeError) as exc:
raise DeserializeError("failed to load SerializableExp") from exc
@@ -120,8 +127,7 @@ class SerializableExp:
@property
def contains_error(self) -> bool:
return (
self.error is not None
or self.params.get("error")
self.params.get("error")
or any(value.get("error") for value in self.params.values())
or self.metrics.get("error")
or any(value.get("error") for value in self.metrics.values())
@@ -129,14 +135,14 @@ class SerializableExp:
@dataclass(frozen=True)
class _ExpDep:
class ExpDep:
hash: Optional[str] # noqa: A003
size: Optional[int]
nfiles: Optional[int]
@dataclass(frozen=True)
class _ExpOut:
class ExpOut:
hash: Optional[str] # noqa: A003
size: Optional[int]
nfiles: Optional[int]
@@ -162,3 +168,52 @@ class SerializableError:
return cls(**parsed)
except (TypeError, json.JSONDecodeError) as exc:
raise DeserializeError("failed to load SerializableError") from exc
@dataclass
class ExpState:
"""Git/DVC experiment state."""
rev: str
name: Optional[str] = None
data: Optional[SerializableExp] = None
error: Optional[SerializableError] = None
experiments: Optional[List["ExpRange"]] = None
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class ExpRange:
revs: List["ExpState"]
executor: Optional["ExpExecutor"] = None
name: Optional[str] = None
def __len__(self) -> int:
return len(self.revs)
def __iter__(self) -> Iterator["ExpState"]:
return iter(self.revs)
def __getitem__(self, index: int) -> "ExpState":
return self.revs[index]
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class LocalExpExecutor:
root: Optional[str] = None
log: Optional[str] = None
pid: Optional[int] = None
returncode: Optional[int] = None
task_id: Optional[str] = None
@dataclass
class ExpExecutor:
state: Literal["success", "queued", "running", "failed"]
name: Optional[str] = None
local: Optional[LocalExpExecutor] = None
+352 -479
View File
@@ -1,517 +1,390 @@
import logging
from collections import OrderedDict, defaultdict
from datetime import datetime
from enum import Enum
from itertools import chain
from collections import Counter, defaultdict
from datetime import date, datetime
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Literal,
Mapping,
NamedTuple,
Optional,
Set,
Tuple,
Union,
)
from scmrepo.exceptions import SCMError as InnerScmError
from dvc.exceptions import InvalidArgumentError
from dvc.scm import Git
from dvc.ui import ui
from dvc.utils.flatten import flatten
from dvc.exceptions import DvcException
from dvc.scm import Git, SCMError, iter_revs, resolve_rev
from .refs import ExpRefInfo
from .serialize import SerializableError, SerializableExp
from .collect import collect
if TYPE_CHECKING:
from scmrepo.git.objects import GitCommit
from dvc.compare import TabularData
from dvc.repo import Repo
from dvc.ui.table import CellT
from .serialize import ExpRange, ExpState
logger = logging.getLogger(__name__)
class ExpStatus(Enum):
Success = 0
Queued = 1
Running = 2
Failed = 3
class _CachedError(DvcException):
def __init__(self, msg, typ, *args):
super().__init__(msg, *args)
self.typ = typ
def _is_scm_error(collected_exp: Dict[str, Any]) -> bool:
if "error" in collected_exp and (
isinstance(collected_exp["error"], (_CachedError, SCMError, InnerScmError))
):
return True
return False
def _format_exp(exp: SerializableExp) -> Dict[str, Any]:
# SerializableExp always includes error but we need to strip it from show
# output when it is false-y to maintain compatibility w/tools that consume
# json output and assume that "error" key presence means there was an error
exp_dict = exp.dumpd()
if "error" in exp_dict and not exp_dict["error"]:
del exp_dict["error"]
return {"data": exp_dict}
def _format_error(error: SerializableError):
msg = error.msg or "None"
return {"data": {}, "error": _CachedError(msg, error.type)}
def collect_experiment_commit(
def show(
repo: "Repo",
exp_rev: str,
status: ExpStatus = ExpStatus.Success,
param_deps: bool = False,
force: bool = False,
revs: Union[List[str], str, None] = None,
all_branches: bool = False,
all_tags: bool = False,
all_commits: bool = False,
num: int = 1,
hide_queued: bool = False,
hide_failed: bool = False,
sha_only: bool = False,
**kwargs,
) -> Dict[str, Any]:
cache = repo.experiments.cache
# TODO: support filtering serialized exp when param_deps is set
if exp_rev != "workspace" and not (force or param_deps):
cached_exp = cache.get(exp_rev)
if cached_exp:
if status == ExpStatus.Running or (
isinstance(cached_exp, SerializableExp)
and cached_exp.status == ExpStatus.Running.name
):
# expire cached queued exp entry once we start running it
cache.delete(exp_rev)
elif isinstance(cached_exp, SerializableError):
return _format_error(cached_exp)
else:
return _format_exp(cached_exp)
try:
exp = _collect_from_repo(
repo,
exp_rev,
status=status,
param_deps=param_deps,
force=force,
**kwargs,
)
if not (
exp_rev == "workspace"
or status == ExpStatus.Running
or param_deps
or exp.contains_error
):
cache.put(exp, force=True)
return _format_exp(exp)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
logger.debug("", exc_info=True)
error = SerializableError(str(exc), type(exc).__name__)
return _format_error(error)
def _collect_from_repo(
repo: "Repo",
exp_rev: str,
status: ExpStatus = ExpStatus.Success,
running: Optional[Dict[str, Any]] = None,
**kwargs,
) -> "SerializableExp":
running = running or {}
with repo.switch(exp_rev) as rev:
if rev == "workspace":
timestamp: Optional[datetime] = None
else:
commit = repo.scm.resolve_commit(rev)
timestamp = datetime.fromtimestamp(commit.commit_time)
if status == ExpStatus.Running:
executor: Optional[str] = running.get(exp_rev, {}).get("location", None)
else:
executor = None
if status == ExpStatus.Failed:
error: Optional["SerializableError"] = SerializableError(
"Experiment run failed."
)
else:
error = None
return SerializableExp.from_repo(
repo,
rev=exp_rev,
timestamp=timestamp,
status=status.name,
executor=executor,
error=error,
)
def _collect_complete_experiment(
repo: "Repo",
baseline: str,
exp_rev: str,
running: Dict[str, Any],
revs: List[str],
**kwargs,
) -> Dict[str, Dict[str, Any]]:
results: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
checkpoint = len(revs) > 1
prev = ""
for rev in revs:
status = ExpStatus.Running if rev in running else ExpStatus.Success
collected_exp = collect_experiment_commit(
repo,
rev,
status=status,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
return {}
if checkpoint:
exp = {"checkpoint_tip": exp_rev}
if prev:
results[prev]["data"][ # type: ignore[unreachable]
"checkpoint_parent"
] = rev
if rev in results:
results[rev]["data"].update(exp)
results.move_to_end(rev)
else:
exp.update(collected_exp["data"])
else:
exp = collected_exp["data"]
if rev not in results:
results[rev] = {"data": exp}
prev = rev
if checkpoint and prev:
results[prev]["data"]["checkpoint_parent"] = baseline
return results
def _collect_branch(
repo: "Repo",
baseline: str,
running: Dict[str, Any],
refs: Optional[Iterable[str]] = None,
**kwargs,
) -> Dict[str, Dict[str, Any]]:
results: Dict[str, Dict[str, Any]] = OrderedDict()
status = ExpStatus.Running if baseline in running else ExpStatus.Success
results["baseline"] = collect_experiment_commit(
) -> List["ExpState"]:
return collect(
repo,
baseline,
status=status,
running=running,
revs=revs,
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
num=num,
hide_queued=hide_queued,
hide_failed=hide_failed,
sha_only=sha_only,
**kwargs,
)
if baseline == "workspace" or _is_scm_error(results["baseline"]):
return results
ref_info = ExpRefInfo(baseline_sha=baseline)
commits: List[Tuple[str, "GitCommit", str, List[str]]] = []
assert isinstance(repo.scm, Git)
if refs:
ref_it = (ref for ref in iter(refs) if ref.startswith(str(ref_info)))
else:
ref_it = repo.scm.iter_refs(base=str(ref_info))
for ref in ref_it:
try:
commit = repo.scm.resolve_commit(ref)
exp_rev = resolve_rev(repo.scm, ref)
revs = list(repo.scm.branch_revs(exp_rev, baseline))
except (SCMError, InnerScmError):
continue
commits.append((ref, commit, exp_rev, revs))
def tabulate(
baseline_states: Iterable["ExpState"],
fill_value: str = "-",
error_value: str = "!",
**kwargs,
) -> Tuple["TabularData", Dict[str, Iterable[str]]]:
"""Return table data for experiments.
for exp_ref, _, exp_rev, revs in sorted(
commits, key=lambda x: x[1].commit_time, reverse=True
):
ref_info = ExpRefInfo.from_ref(exp_ref)
assert ref_info.baseline_sha == baseline
collected_exp = _collect_complete_experiment(
repo,
baseline=baseline,
exp_rev=exp_rev,
running=running,
revs=revs,
Returns:
Tuple of (table_data, data_headers)
"""
from funcy import lconcat
from funcy.seqs import flatten as flatten_list
from dvc.compare import TabularData
data_names = _collect_names(baseline_states)
metrics_names = data_names.metrics
params_names = data_names.params
deps_names = data_names.sorted_deps
headers = [
"Experiment",
"rev",
"typ",
"Created",
"parent",
"State",
"Executor",
]
names = {**metrics_names, **params_names}
counter = Counter(flatten_list([list(a.keys()) for a in names.values()]))
counter.update(headers)
metrics_headers = _normalize_headers(metrics_names, counter)
params_headers = _normalize_headers(params_names, counter)
all_headers = lconcat(headers, metrics_headers, params_headers, deps_names)
td = TabularData(all_headers, fill_value=fill_value)
td.extend(
_build_rows(
baseline_states,
all_headers=all_headers,
metrics_headers=metrics_headers,
params_headers=params_headers,
metrics_names=metrics_names,
params_names=params_names,
deps_names=deps_names,
fill_value=fill_value,
error_value=error_value,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
results.update(collected_exp)
return results
)
data_headers: Dict[str, Iterable[str]] = {
"metrics": metrics_headers,
"params": params_headers,
"deps": deps_names,
}
return td, data_headers
def get_branch_names(
scm: "Git", baseline_set: Iterable[str], refs: Optional[Iterable[str]] = None
) -> Dict[str, Optional[str]]:
names: Dict[str, Optional[str]] = {}
bases = [
f"refs/exps/{baseline[:2]}/{baseline[2:]}/" for baseline in baseline_set
] + ["refs/heads/", "refs/tags/"]
ref_it = iter(refs) if refs else scm.iter_refs()
for ref in ref_it:
for base in bases:
if ref.startswith(base):
try:
rev = scm.get_ref(ref)
names[rev] = ref[len(base) :]
except KeyError:
logger.debug("unresolved ref %s", ref)
break
logger.debug("found refs for revs: %s", names)
return names
def update_names( # noqa: C901
repo: "Repo",
branch_names: Dict[str, Optional[str]],
result: Dict[str, Dict[str, Any]],
):
rev_set = set()
for baseline in result:
for rev in result[baseline]:
if rev == "baseline":
rev = baseline
if rev != "workspace":
rev_set.add(rev)
if rev_set:
rev_set.difference_update(branch_names.keys())
exact_name = repo.experiments.get_exact_name(rev_set)
for baseline, baseline_results in result.items():
name_set: Set[str] = set()
for rev, rev_result in baseline_results.items():
name: Optional[str] = None
if rev == "baseline":
rev = baseline
if rev == "workspace":
continue
name = branch_names.get(rev, None) or exact_name[rev]
if name and name not in name_set:
name_set.add(name)
rev_result["data"]["name"] = name
def _collect_active_experiment(
repo: "Repo",
found_revs: Dict[str, List[str]],
running: Dict[str, Any],
def _build_rows(
baseline_states: Iterable["ExpState"],
*,
all_headers: Iterable[str],
fill_value: str,
sort_by: Optional[str] = None,
sort_order: Optional[Literal["asc", "desc"]] = None,
**kwargs,
) -> Dict[str, Dict[str, Any]]:
result: Dict[str, Dict] = defaultdict(OrderedDict)
for entry in chain(
repo.experiments.tempdir_queue.iter_active(),
repo.experiments.celery_queue.iter_active(),
):
stash_rev = entry.stash_rev
if entry.baseline_rev in found_revs and (
stash_rev not in running or not running[stash_rev].get("last")
) -> Iterator[Tuple["CellT", ...]]:
for baseline in baseline_states:
row: Dict[str, "CellT"] = {k: fill_value for k in all_headers}
row["Experiment"] = ""
if baseline.name:
row["rev"] = baseline.name
elif Git.is_sha(baseline.rev):
row["rev"] = baseline.rev[:7]
else:
row["rev"] = baseline.rev
row["typ"] = "baseline"
row["parent"] = ""
if baseline.data:
row["Created"] = format_time(
baseline.data.timestamp, fill_value=fill_value, **kwargs
)
row.update(
_data_cells( # pylint: disable=missing-kwoa
baseline, fill_value=fill_value, **kwargs
)
)
yield tuple(row.values())
if baseline.experiments:
if sort_by:
metrics_names: Mapping[str, Iterable[str]] = kwargs.get(
"metrics_names", {}
)
params_names: Mapping[str, Iterable[str]] = kwargs.get(
"params_names", {}
)
sort_path, sort_name, sort_type = _sort_column(
sort_by, metrics_names, params_names
)
reverse = sort_order == "desc"
experiments = _sort_exp(
baseline.experiments, sort_path, sort_name, sort_type, reverse
)
else:
experiments = baseline.experiments
for i, child in enumerate(experiments):
yield from _exp_range_rows(
child,
all_headers=all_headers,
fill_value=fill_value,
is_base=i == len(baseline.experiments) - 1,
**kwargs,
)
def _sort_column(
sort_by: str,
metric_names: Mapping[str, Iterable[str]],
param_names: Mapping[str, Iterable[str]],
) -> Tuple[str, str, str]:
path, _, sort_name = sort_by.rpartition(":")
matches: Set[Tuple[str, str, str]] = set()
if path:
if path in metric_names and sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
if path in param_names and sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
else:
for path in metric_names:
if sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
for path in param_names:
if sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
if len(matches) == 1:
return matches.pop()
if len(matches) > 1:
raise InvalidArgumentError(
"Ambiguous sort column '{}' matched '{}'".format(
sort_by,
", ".join([f"{path}:{name}" for path, name, _ in matches]),
)
)
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
def _sort_exp(
experiments: Iterable["ExpRange"],
sort_path: str,
sort_name: str,
typ: str,
reverse: bool,
) -> List["ExpRange"]:
from funcy import first
def _sort(exp_range: "ExpRange"):
exp = first(exp_range.revs)
if not exp:
return True
data = exp.data.dumpd().get(typ, {}).get(sort_path, {}).get("data", {})
val = flatten(data).get(sort_name)
return val is None, val
return sorted(experiments, key=_sort, reverse=reverse)
def _exp_range_rows(
exp_range: "ExpRange",
*,
all_headers: Iterable[str],
fill_value: str,
is_base: bool = False,
**kwargs,
) -> Iterator[Tuple["CellT", ...]]:
for i, exp in enumerate(exp_range.revs):
row: Dict[str, "CellT"] = {k: fill_value for k in all_headers}
row["Experiment"] = exp.name or ""
row["rev"] = exp.rev[:7] if Git.is_sha(exp.rev) else exp.rev
if len(exp_range.revs) > 1:
if i == 0:
row["typ"] = "checkpoint_tip"
elif i == len(exp_range.revs) - 1:
row["typ"] = "checkpoint_base"
else:
row["typ"] = "checkpoint_commit"
else:
row["typ"] = "branch_base" if is_base else "branch_commit"
row["parent"] = ""
if exp_range.executor:
row["State"] = exp_range.executor.state.capitalize()
if exp_range.executor.name:
row["Executor"] = exp_range.executor.name.capitalize()
if exp.data:
row["Created"] = format_time(
exp.data.timestamp, fill_value=fill_value, **kwargs
)
row.update(
_data_cells( # pylint: disable=missing-kwoa
exp, fill_value=fill_value, **kwargs
)
)
yield tuple(row.values())
def _data_cells(
exp: "ExpState",
*,
metrics_headers: Iterable[str],
params_headers: Iterable[str],
metrics_names: Mapping[str, Iterable[str]],
params_names: Mapping[str, Iterable[str]],
deps_names: Iterable[str],
fill_value: str = "-",
error_value: str = "!",
precision: Optional[int] = None,
**kwargs,
) -> Iterator[Tuple[str, "CellT"]]:
def _d_cells(
d: Mapping[str, Any],
names: Mapping[str, Iterable[str]],
headers: Iterable[str],
) -> Iterator[Tuple[str, "CellT"]]:
from dvc.compare import _format_field, with_value
for fname, data in d.items():
item = data.get("data", {})
item = flatten(item) if isinstance(item, dict) else {fname: item}
for name in names[fname]:
value = with_value(
item.get(name),
error_value if data.get("error") else fill_value,
)
# wrap field data in ui.rich_text, otherwise rich may
# interpret unescaped braces from list/dict types as rich
# markup tags
value = ui.rich_text(str(_format_field(value, precision)))
if name in headers:
yield name, value
else:
yield f"{fname}:{name}", value
if not exp.data:
return
yield from _d_cells(exp.data.metrics, metrics_names, metrics_headers)
yield from _d_cells(exp.data.params, params_names, params_headers)
for name in deps_names:
dep = exp.data.deps.get(name)
if dep:
yield name, dep.hash or fill_value
def format_time(
timestamp: Optional[datetime],
fill_value: str = "-",
iso: bool = False,
**kwargs,
) -> str:
if not timestamp:
return fill_value
if iso:
return timestamp.isoformat()
if timestamp.date() == date.today():
fmt = "%I:%M %p"
else:
fmt = "%b %d, %Y"
return timestamp.strftime(fmt)
class _DataNames(NamedTuple):
# NOTE: we use nested dict instead of set for metrics/params names to
# preserve key ordering
metrics: Dict[str, Dict[str, Any]]
params: Dict[str, Dict[str, Any]]
deps: Set[str]
@property
def sorted_deps(self):
return sorted(self.deps)
def update(self, other: "_DataNames"):
def _update_d(
d: Dict[str, Dict[str, Any]], other_d: Mapping[str, Mapping[str, Any]]
):
collected_exp = collect_experiment_commit(
repo,
stash_rev,
status=ExpStatus.Running,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
result[entry.baseline_rev][stash_rev] = collected_exp
for k, v in other_d.items():
if k in d:
d[k].update(v)
else:
d[k] = dict(v)
_update_d(self.metrics, other.metrics)
_update_d(self.params, other.params)
self.deps.update(other.deps)
def _collect_names(exp_states: Iterable["ExpState"]) -> _DataNames:
result = _DataNames(defaultdict(dict), defaultdict(dict), set())
def _collect_d(result_d: Dict[str, Dict[str, Any]], data_d: Dict[str, Any]):
for path, item in data_d.items():
item = item.get("data", {})
if isinstance(item, dict):
item = flatten(item)
result_d[path].update((key, None) for key in item)
for exp in exp_states:
if exp.data:
_collect_d(result.metrics, exp.data.metrics)
_collect_d(result.params, exp.data.params)
result.deps.update(exp.data.deps)
if exp.experiments:
for child in exp.experiments:
result.update(_collect_names(child.revs))
return result
def _collect_queued_experiment(
repo: "Repo",
found_revs: Dict[str, List[str]],
running: Dict[str, Any],
**kwargs,
) -> Dict[str, Dict[str, Any]]:
result: Dict[str, Dict] = defaultdict(OrderedDict)
for entry in repo.experiments.celery_queue.iter_queued():
stash_rev = entry.stash_rev
if entry.baseline_rev in found_revs:
collected_exp = collect_experiment_commit(
repo,
stash_rev,
status=ExpStatus.Queued,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
result[entry.baseline_rev][stash_rev] = collected_exp
return result
def _collect_failed_experiment(
repo: "Repo",
found_revs: Dict[str, List[str]],
running: Dict[str, Any],
**kwargs,
) -> Dict[str, Dict[str, Any]]:
result: Dict[str, Dict] = defaultdict(OrderedDict)
for queue_done_result in repo.experiments.celery_queue.iter_failed():
entry = queue_done_result.entry
stash_rev = entry.stash_rev
if entry.baseline_rev in found_revs:
collected_exp = collect_experiment_commit(
repo,
stash_rev,
status=ExpStatus.Failed,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
result[entry.baseline_rev][stash_rev] = collected_exp
return result
def update_new(
to_dict: Dict[str, Dict[str, Any]], from_dict: Dict[str, Dict[str, Any]]
):
for baseline, experiments in from_dict.items():
for rev, experiment in experiments.items():
to_dict[baseline][rev] = to_dict[baseline].get(rev, experiment)
def move_properties_to_head(result: Dict[str, Dict[str, Dict[str, Any]]]):
for _, baseline_results in result.items():
checkpoint: bool = False
head: Dict[str, Any] = {}
for rev, rev_data in baseline_results.items():
if (
"data" not in rev_data
or rev_data["data"].get("checkpoint_tip", None) is None
):
checkpoint = False
head = {}
continue
rev_result: Dict[str, Any] = rev_data["data"]
if (
checkpoint is True
and rev_result["checkpoint_tip"] == head["checkpoint_tip"]
):
if "name" in rev_result and "name" not in head:
head["name"] = rev_result["name"]
del rev_result["name"]
if rev_result["executor"]:
if not head["executor"]:
head["executor"] = rev_result["executor"]
rev_result["executor"] = None
if rev_result["status"] == ExpStatus.Running.name:
head["status"] = ExpStatus.Running.name
rev_result["status"] = ExpStatus.Success.name
elif rev_result["checkpoint_tip"] == rev:
head = rev_result
checkpoint = True
def show( # noqa: PLR0913
repo: "Repo",
all_branches=False,
all_tags=False,
revs: Union[List[str], str, None] = None,
all_commits=False,
hide_queued=False,
hide_failed=False,
sha_only=False,
num=1,
param_deps=False,
onerror: Optional[Callable] = None,
fetch_running: bool = True,
force: bool = False,
):
if repo.scm.no_commits:
return {}
res: Dict[str, Dict] = defaultdict(OrderedDict)
if not any([revs, all_branches, all_tags, all_commits]):
revs = ["HEAD"]
if isinstance(revs, str):
revs = [revs]
assert isinstance(repo.scm, Git)
found_revs: Dict[str, List[str]] = {"workspace": []}
found_revs.update(
iter_revs(repo.scm, revs, num, all_branches, all_tags, all_commits)
)
cached_refs = list(repo.scm.iter_refs())
branch_names = get_branch_names(repo.scm, found_revs, refs=cached_refs)
running: Dict[str, Dict] = repo.experiments.get_running_exps(
fetch_refs=fetch_running
)
queued_experiment = (
_collect_queued_experiment(
repo,
found_revs,
running,
param_deps=param_deps,
force=force,
)
if not hide_queued
else {}
)
active_experiment = _collect_active_experiment(
repo,
found_revs,
running,
param_deps=param_deps,
onerror=onerror,
force=force,
)
failed_experiments = (
_collect_failed_experiment(
repo,
found_revs,
running,
param_deps=param_deps,
onerror=onerror,
force=force,
)
if not hide_failed
else {}
)
for baseline in found_revs:
res[baseline] = _collect_branch(
repo,
baseline,
running=running,
param_deps=param_deps,
onerror=onerror,
force=force,
refs=cached_refs,
)
update_new(res, failed_experiments)
update_new(res, active_experiment)
update_new(res, queued_experiment)
if not sha_only:
update_names(repo, branch_names, res)
move_properties_to_head(res)
return res
def _normalize_headers(
names: Mapping[str, Mapping[str, Any]], count: Mapping[str, int]
) -> List[str]:
return [
name if count[name] == 1 else f"{path}:{name}"
for path in names
for name in names[path]
]
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -52,7 +52,12 @@ def get(url, path, out=None, rev=None, jobs=None):
cache_types = ["reflink", "hardlink", "copy"]
try:
with external_repo(
url=url, rev=rev, cache_dir=tmp_dir, cache_types=cache_types
url=url,
rev=rev,
subrepos=True,
uninitialized=True,
cache_dir=tmp_dir,
cache_types=cache_types,
) as repo:
from dvc.fs.data import DataFileSystem
+3 -3
View File
@@ -110,10 +110,10 @@ def build_graph(stages, outs_trie=None):
# Building graph
graph.add_nodes_from(stages)
for stage in stages:
for dep in stage.deps:
if dep.fs_path is None:
continue
if stage.is_repo_import:
continue
for dep in stage.deps:
dep_key = dep.fs.path.parts(dep.fs_path)
overlapping = [n.value for n in outs_trie.prefixes(dep_key)]
if outs_trie.has_subtrie(dep_key):
+3 -2
View File
@@ -36,9 +36,10 @@ def imp_url( # noqa: C901, PLR0913
out = resolve_output(url, out)
path, wdir, out = resolve_paths(self, out, always_local=to_remote and not out)
if to_remote and (no_exec or no_download):
if to_remote and (no_exec or no_download or version_aware):
raise InvalidArgumentError(
"--no-exec/--no-download cannot be combined with --to-remote"
"--no-exec/--no-download/--version-aware cannot be combined with "
"--to-remote"
)
if not to_remote and remote:
+4 -13
View File
@@ -28,11 +28,7 @@ def unfetched_view(
changed_deps: List["Dependency"] = []
def need_fetch(stage: "Stage") -> bool:
if (
not stage.is_import
or stage.is_repo_import
or (stage.is_partial_import and not unpartial)
):
if not stage.is_import or (stage.is_partial_import and not unpartial):
return False
out = stage.outs[0]
@@ -65,6 +61,7 @@ def unpartial_imports(index: Union["Index", "IndexView"]) -> int:
Total number of files which were unpartialed.
"""
from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.hashfile.meta import Meta
updated = 0
for out in index.outs:
@@ -73,14 +70,8 @@ def unpartial_imports(index: Union["Index", "IndexView"]) -> int:
workspace, key = out.index_key
entry = index.data[workspace][key]
if out.stage.is_partial_import:
if out.stage.is_repo_import:
dep = out.stage.deps[0]
out.hash_info = dep.get_obj().hash_info
out.meta = dep.get_meta()
else:
assert isinstance(entry.hash_info, HashInfo)
out.hash_info = entry.hash_info
out.meta = entry.meta
out.hash_info = entry.hash_info or HashInfo()
out.meta = entry.meta or Meta()
out.stage.md5 = out.stage.compute_md5()
out.stage.dump()
updated += out.meta.nfiles if out.meta.nfiles is not None else 1
+11 -3
View File
@@ -161,7 +161,7 @@ def _load_storage_from_out(storage_map, key, out):
except NoRemoteError:
pass
if out.stage.is_import and not out.stage.is_repo_import:
if out.stage.is_import:
dep = out.stage.deps[0]
storage_map.add_data(FileStorage(key, dep.fs, dep.fs_path))
@@ -174,12 +174,14 @@ class Index:
metrics: Optional[Dict[str, List[str]]] = None,
plots: Optional[Dict[str, List[str]]] = None,
params: Optional[Dict[str, Any]] = None,
artifacts: Optional[Dict[str, Any]] = None,
) -> None:
self.repo = repo
self.stages = stages or []
self._metrics = metrics or {}
self._plots = plots or {}
self._params = params or {}
self._artifacts = artifacts or {}
self._collected_targets: Dict[int, List["StageInfo"]] = {}
@cached_property
@@ -202,6 +204,7 @@ class Index:
metrics = {}
plots = {}
params = {}
artifacts = {}
onerror = onerror or repo.stage_collection_error_handler
for _, idx in collect_files(repo, onerror=onerror):
@@ -210,12 +213,14 @@ class Index:
metrics.update(idx._metrics)
plots.update(idx._plots)
params.update(idx._params)
artifacts.update(idx._artifacts)
return cls(
repo,
stages=stages,
metrics=metrics,
plots=plots,
params=params,
artifacts=artifacts,
)
@classmethod
@@ -229,6 +234,7 @@ class Index:
metrics={path: dvcfile.metrics} if dvcfile.metrics else {},
plots={path: dvcfile.plots} if dvcfile.plots else {},
params={path: dvcfile.params} if dvcfile.params else {},
artifacts={path: dvcfile.artifacts} if dvcfile.artifacts else {},
)
def update(self, stages: Iterable["Stage"]) -> "Index":
@@ -242,6 +248,7 @@ class Index:
metrics=self._metrics,
plots=self._plots,
params=self._params,
artifacts=self._artifacts,
)
@cached_property
@@ -264,7 +271,7 @@ class Index:
def check_graph(self) -> None:
if not getattr(self.repo, "_skip_graph_checks", False):
self.graph # pylint: disable=pointless-statement
self.graph # noqa: B018, pylint: disable=pointless-statement
@property
def params(self) -> Iterator["ParamsDependency"]:
@@ -530,7 +537,8 @@ class IndexView:
workspace, key = out.index_key
if filter_info and out.fs.path.isin(filter_info, out.fs_path):
key = key + out.fs.path.relparts(filter_info, out.fs_path)
if out.meta.isdir or out.stage.is_import and out.stage.deps[0].meta.isdir:
entry = self._index.data[workspace][key]
if entry and entry.meta and entry.meta.isdir:
prefixes[workspace].recursive.add(key)
prefixes[workspace].explicit.update(key[:i] for i in range(len(key), 0, -1))
return prefixes
+9 -1
View File
@@ -12,7 +12,7 @@ from dvc.utils.fs import remove
logger = logging.getLogger(__name__)
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False): # noqa: C901
"""
Creates an empty repo on the given directory -- basically a
`.dvc` directory with subdirectories for configuration and cache.
@@ -75,6 +75,14 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
proj = Repo(root_dir)
if os.path.isdir(proj.site_cache_dir):
proj.close()
try:
remove(proj.site_cache_dir)
except OSError:
logger.debug("failed to remove %s", dvc_dir, exc_info=True)
proj = Repo(root_dir)
with proj.scm_context(autostage=True) as context:
files = [
config.files["repo"],
+3
View File
@@ -20,16 +20,19 @@ def pre_commit_install(scm: "Git") -> None:
"hooks": [
{
"id": "dvc-pre-commit",
"additional_dependencies": [".[all]"],
"language_version": "python3",
"stages": ["commit"],
},
{
"id": "dvc-pre-push",
"additional_dependencies": [".[all]"],
"language_version": "python3",
"stages": ["push"],
},
{
"id": "dvc-post-checkout",
"additional_dependencies": [".[all]"],
"language_version": "python3",
"stages": ["post-checkout"],
"always_run": True,
+1 -6
View File
@@ -1,8 +1,6 @@
import os
from typing import TYPE_CHECKING, Optional
from dvc.exceptions import PathMissingError
if TYPE_CHECKING:
from dvc.fs.dvc import DVCFileSystem
@@ -61,10 +59,7 @@ def _ls(
fs: "DVCFileSystem" = repo.dvcfs
fs_path = fs.from_os_path(path)
try:
fs_path = fs.info(fs_path)["name"]
except FileNotFoundError:
raise PathMissingError(path, repo, dvc_only=dvc_only) # noqa: B904
fs_path = fs.info(fs_path)["name"]
infos = {}
for root, dirs, files in fs.walk(
+54 -33
View File
@@ -20,13 +20,14 @@ from typing import (
import dpath
import dpath.options
from funcy import distinct, first, project
from funcy import first, ldistinct, project, reraise
from dvc.exceptions import DvcException
from dvc.utils import error_handler, errored_revisions, onerror_collect
from dvc.utils.objects import cached_property
from dvc.utils.serialize import LOADERS
from dvc.utils.serialize import PARSERS, EncodingError
from dvc.utils.threadpool import ThreadPoolExecutor
from dvc_render.image import ImageRenderer
if TYPE_CHECKING:
from dvc.fs import FileSystem
@@ -39,6 +40,9 @@ dpath.options.ALLOW_EMPTY_STRING_KEYS = True
logger = logging.getLogger(__name__)
SUPPORTED_IMAGE_EXTENSIONS = ImageRenderer.EXTENSIONS
class PlotMetricTypeError(DvcException):
def __init__(self, file):
super().__init__(
@@ -60,7 +64,11 @@ class PropsNotFoundError(DvcException):
@error_handler
def _unpack_dir_files(fs, path, **kwargs):
return list(fs.find(path))
ret = list(fs.find(path))
if not ret:
# This will raise FileNotFoundError if it is a broken symlink or TreeError
next(iter(fs.ls(path)), None)
return ret
class Plots:
@@ -153,9 +161,7 @@ class Plots:
props: Optional[Dict] = None,
onerror: Optional[Callable] = None,
):
from dvc.fs.dvc import DVCFileSystem
fs = DVCFileSystem(repo=self.repo)
fs = self.repo.dvcfs
props = props or {}
@@ -196,7 +202,10 @@ class Plots:
onerror=onerror,
props=props,
):
_resolve_data_sources(data)
short_rev = "workspace"
if rev := getattr(self.repo.fs, "rev", None):
short_rev = rev[:7]
_resolve_data_sources(data, short_rev, cache_remote_stream=True)
result.update(data)
errored = errored_revisions(result)
@@ -265,7 +274,11 @@ def _is_plot(out: "Output") -> bool:
return bool(out.plot)
def _resolve_data_sources(plots_data: Dict):
def _resolve_data_sources(
plots_data: Dict, rev: str, cache_remote_stream: bool = False
):
from dvc.progress import Tqdm
values = list(plots_data.values())
to_resolve = []
while values:
@@ -278,16 +291,26 @@ def _resolve_data_sources(plots_data: Dict):
def resolve(value):
data_source = value.pop("data_source")
assert callable(data_source)
value.update(data_source())
value.update(data_source(cache_remote_stream=cache_remote_stream))
if not to_resolve:
return
executor = ThreadPoolExecutor(
max_workers=4 * cpu_count(),
max_workers=min(16, 4 * cpu_count()),
thread_name_prefix="resolve_data",
cancel_on_error=True,
)
with executor:
# imap_unordered is lazy, wrapping to trigger it
list(executor.imap_unordered(resolve, to_resolve))
iterable = executor.imap_unordered(resolve, to_resolve)
with Tqdm(
iterable,
total=len(to_resolve),
desc=f"Reading plot's data from {rev}",
unit="files",
unit_scale=False,
) as progress_iterable:
list(progress_iterable)
def _collect_plots(
@@ -333,7 +356,7 @@ def infer_data_sources(plot_id, config=None):
if isinstance(x, dict):
sources.append(first(x.keys()))
return distinct(source for source in sources)
return ldistinct(source for source in sources)
def _matches(targets, config_file, plot_id):
@@ -485,10 +508,7 @@ def _collect_definitions(
result: Dict = defaultdict(dict)
props = props or {}
from dvc.fs.dvc import DVCFileSystem
fs = DVCFileSystem(repo=repo)
fs = repo.dvcfs
dpath.merge(
result,
_collect_pipeline_files(repo, targets, props, onerror=onerror),
@@ -524,20 +544,25 @@ def unpack_if_dir(fs, path, props: Dict[str, str], onerror: Optional[Callable] =
@error_handler
def parse(fs, path, props=None, **kwargs):
def parse(fs, path, props=None, **fs_kwargs):
props = props or {}
_, extension = os.path.splitext(path)
if extension in (".tsv", ".csv"):
header = props.get("header", True)
if extension == ".csv":
return _load_sv(path=path, fs=fs, delimiter=",", header=header)
return _load_sv(path=path, fs=fs, delimiter="\t", header=header)
if extension in LOADERS or extension in (".yml", ".yaml"):
return LOADERS[extension](path=path, fs=fs)
if extension in (".jpeg", ".jpg", ".gif", ".png", ".svg"):
with fs.open(path, "rb") as fd:
if extension in SUPPORTED_IMAGE_EXTENSIONS:
with fs.open(path, mode="rb", **fs_kwargs) as fd:
return fd.read()
raise PlotMetricTypeError(path)
if extension not in PARSERS.keys() | {".yml", ".yaml", ".csv", ".tsv"}:
raise PlotMetricTypeError(path)
with reraise(UnicodeDecodeError, EncodingError(path, "utf8")):
with fs.open(path, mode="r", encoding="utf8", **fs_kwargs) as fd:
contents = fd.read()
if extension in (".csv", ".tsv"):
header = props.get("header", True)
delim = "\t" if extension == ".tsv" else ","
return _load_sv(contents, delimiter=delim, header=header)
return PARSERS[extension](contents, path)
def _plot_props(out: "Output") -> Dict:
@@ -553,10 +578,7 @@ def _plot_props(out: "Output") -> Dict:
return project(out.plot, PLOT_PROPS)
def _load_sv(path, fs, delimiter=",", header=True):
with fs.open(path, "r") as fd:
content = fd.read()
def _load_sv(content, delimiter=",", header=True):
if header:
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
else:
@@ -566,5 +588,4 @@ def _load_sv(path, fs, delimiter=",", header=True):
delimiter=delimiter,
fieldnames=[str(i) for i in range(len(first_row))],
)
return list(reader)
+12 -3
View File
@@ -76,12 +76,13 @@ def _track_stage(stage: "Stage") -> None:
@locked
@scm_context
def reproduce( # noqa: C901
def reproduce( # noqa: C901, PLR0912
self: "Repo",
targets=None,
recursive=False,
pipeline=False,
all_pipelines=False,
after_repro_callback=None,
**kwargs,
):
from .graph import get_pipeline, get_pipelines
@@ -125,11 +126,19 @@ def reproduce( # noqa: C901
)
)
return _reproduce_stages(self.index.graph, list(stages), **kwargs)
result = _reproduce_stages(self.index.graph, list(stages), **kwargs)
if callable(after_repro_callback):
after_repro_callback()
return result
def _reproduce_stages( # noqa: C901
graph, stages, downstream=False, single_item=False, on_unchanged=None, **kwargs
graph,
stages,
downstream=False,
single_item=False,
on_unchanged=None,
**kwargs,
):
r"""Derive the evaluation of the given node for the given graph.
+11 -2
View File
@@ -5,7 +5,11 @@ from contextlib import suppress
from functools import wraps
from typing import Iterable, List, NamedTuple, Optional, Set, Tuple, Union
from dvc.exceptions import NoOutputOrStageError, OutputNotFoundError
from dvc.exceptions import (
NoOutputOrStageError,
OutputDuplicationError,
OutputNotFoundError,
)
from dvc.repo import lock_repo
from dvc.ui import ui
from dvc.utils import as_posix, parse_target
@@ -176,7 +180,12 @@ class StageLoad:
check_stage_exists(self.repo, stage, stage.path)
self.repo.check_graph(stages={stage})
try:
self.repo.check_graph(stages={stage})
except OutputDuplicationError as exc:
# Don't include the stage currently being added.
exc.stages.remove(stage)
raise OutputDuplicationError(exc.output, exc.stages) from None
restore_fields(stage)
return stage
+2 -4
View File
@@ -1,7 +1,5 @@
import logging
from itertools import compress
from funcy.py3 import cat
from itertools import chain, compress
from dvc.exceptions import InvalidArgumentError
@@ -29,7 +27,7 @@ def _joint_status(pairs):
def _local_status(self, targets=None, with_deps=False, recursive=False):
targets = targets or [None]
pairs = cat(
pairs = chain.from_iterable(
self.stage.collect_granular(t, with_deps=with_deps, recursive=recursive)
for t in targets
)
+5 -1
View File
@@ -3,7 +3,7 @@ from collections.abc import Mapping
from voluptuous import Any, Optional, Required, Schema
from dvc import dependency, output
from dvc.annotations import ANNOTATION_SCHEMA
from dvc.annotations import ANNOTATION_SCHEMA, ARTIFACT_SCHEMA
from dvc.output import (
CHECKSUMS_SCHEMA,
CLOUD_SCHEMA,
@@ -114,6 +114,9 @@ PLOT_DEFINITION = {
Output.PARAM_PLOT_TEMPLATE: str,
}
SINGLE_PLOT_SCHEMA = {str: Any(PLOT_DEFINITION, None)}
ARTIFACTS = "artifacts"
SINGLE_ARTIFACT_SCHEMA = Schema({str: ARTIFACT_SCHEMA})
ARTIFACTS_SCHEMA = Any(str, SINGLE_ARTIFACT_SCHEMA)
FOREACH_IN = {
Required(FOREACH_KWD): Any(dict, list, str),
Required(DO_KWD): STAGE_DEFINITION,
@@ -127,6 +130,7 @@ MULTI_STAGE_SCHEMA = {
VARS_KWD: VARS_SCHEMA,
StageParams.PARAM_PARAMS: [str],
StageParams.PARAM_METRICS: [str],
ARTIFACTS: ARTIFACTS_SCHEMA,
}
COMPILED_SINGLE_STAGE_SCHEMA = Schema(SINGLE_STAGE_SCHEMA)
+4 -2
View File
@@ -1,7 +1,7 @@
import functools
import logging
import os
import subprocess
import subprocess # nosec B404
import threading
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, List
@@ -96,7 +96,9 @@ class Monitor:
def _kill_nt(proc):
# windows stages are spawned with shell=True, proc is the shell process
# and not the actual stage process - we have to kill the entire tree
subprocess.call(["taskkill", "/F", "/T", "/PID", str(proc.pid)])
subprocess.call( # nosec B607, B603
["taskkill", "/F", "/T", "/PID", str(proc.pid)]
)
@staticmethod
def _loop(tasks: List[MonitorTask], done: threading.Event):
+2 -2
View File
@@ -1,7 +1,7 @@
import logging
import os
import signal
import subprocess
import subprocess # nosec B404
import threading
from typing import TYPE_CHECKING, List
@@ -86,7 +86,7 @@ def _run(stage: "Stage", executable, cmd, checkpoint_func, **kwargs):
exec_cmd = _make_cmd(executable, cmd)
try:
p = subprocess.Popen(exec_cmd, **kwargs)
p = subprocess.Popen(exec_cmd, **kwargs) # nosec B603
if main_thread:
old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
+5 -3
View File
@@ -1,5 +1,5 @@
import os
import subprocess
import subprocess # nosec B404
from typing import Dict, Tuple
import pytest
@@ -233,14 +233,16 @@ def docker_services(
pytest.skip("disabled for Windows on CI")
try:
subprocess.check_output("docker ps", stderr=subprocess.STDOUT, shell=True)
subprocess.check_output( # nosec B607, B602
"docker ps", stderr=subprocess.STDOUT, shell=True
)
except subprocess.CalledProcessError as err:
out = (err.output or b"").decode("utf-8")
pytest.skip(f"docker is not installed or the daemon is not running: {out}")
try:
cmd = "docker-compose version"
subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True)
subprocess.check_output(cmd, stderr=subprocess.STDOUT, shell=True) # nosec
except subprocess.CalledProcessError as err:
out = (err.output or b"").decode("utf-8")
pytest.skip(f"docker-compose is not installed: {out}")
+9 -6
View File
@@ -149,11 +149,11 @@ class TmpDir(pathlib.Path):
paths = self.gen(struct, text)
return self.dvc_add(paths, commit=commit)
def scm_gen(self, struct, text="", commit=None):
def scm_gen(self, struct, text="", commit=None, force=False):
paths = self.gen(struct, text)
return self.scm_add(paths, commit=commit)
return self.scm_add(paths, commit=commit, force=force)
def commit(self, output_paths, msg):
def commit(self, output_paths, msg, force=False):
def to_gitignore(stage_path):
from dvc.scm import Git
@@ -162,7 +162,7 @@ class TmpDir(pathlib.Path):
gitignores = [
to_gitignore(s) for s in output_paths if os.path.exists(to_gitignore(s))
]
return self.scm_add(output_paths + gitignores, commit=msg)
return self.scm_add(output_paths + gitignores, commit=msg, force=force)
def dvc_add(self, filenames, commit=None):
self._require("dvc")
@@ -173,10 +173,13 @@ class TmpDir(pathlib.Path):
self.commit([s.path for s in stages], msg=commit)
return stages
def scm_add(self, filenames, commit=None):
def scm_add(self, filenames, commit=None, force=False):
from dvc.scm import Git
self._require("scm")
filenames = _coerce_filenames(filenames)
self.scm.add(filenames)
assert isinstance(self.scm, Git)
self.scm.add(filenames, force=force)
if commit:
self.scm.commit(commit)
+25 -2
View File
@@ -1,4 +1,4 @@
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from typing import (
TYPE_CHECKING,
Any,
@@ -13,6 +13,8 @@ from typing import (
Union,
)
import colorama
from dvc.utils.objects import cached_property
if TYPE_CHECKING:
@@ -27,6 +29,21 @@ if TYPE_CHECKING:
from dvc.ui.table import Headers, Styles, TableData
@contextmanager
def disable_colorama():
import sys
colorama.deinit()
try:
yield
finally:
if sys.stdout:
sys.stdout.flush()
if sys.stderr:
sys.stderr.flush()
colorama.reinit()
class Formatter:
def __init__(
self, theme: Optional[Dict] = None, defaults: Optional[Dict] = None
@@ -121,7 +138,13 @@ class Console:
sort_keys=sort_keys,
)
if not highlight:
return self.write(json.text, stderr=stderr)
import os
# we don't need colorama to try to strip ansi codes
# when highlighting is disabled
ctx = nullcontext() if "DVC_TEST" in os.environ else disable_colorama()
with ctx:
return self.write(json.text, stderr=stderr)
return self.rich_print(json, stderr=stderr, soft_wrap=True)
def rich_print(
+1 -1
View File
@@ -59,7 +59,7 @@ def find_pager():
if not pager:
pager = os.getenv(PAGER_ENV)
if not pager:
if os.system(f"({DEFAULT_PAGER}) 2>{os.devnull}") != 0:
if os.system(f"({DEFAULT_PAGER}) 2>{os.devnull}") != 0: # nosec B605
logger.warning(
"Unable to find `less` in the PATH. Check out %s for more info.",
format_link("https://man.dvc.org/pipeline/show"),
+1 -1
View File
@@ -166,7 +166,7 @@ class Updater:
from dvc.config import Config, to_bool
enabled = to_bool(
Config(validate=False).get("core", {}).get("check_update", "true")
Config.from_cwd(validate=False).get("core", {}).get("check_update", "true")
)
logger.debug("Check for update is %sabled.", "en" if enabled else "dis")
return enabled
+2
View File
@@ -36,6 +36,8 @@ def compose_and_dump(
with initialize_config_dir(config_dir, version_base=None):
cfg = compose(config_name=config_name, overrides=overrides)
OmegaConf.resolve(cfg)
suffix = Path(output_file).suffix.lower()
if suffix not in [".yml", ".yaml"]:
dumper = DUMPERS[suffix]
+7
View File
@@ -12,6 +12,13 @@ LOADERS: DefaultDict[str, LoaderFn] = defaultdict( # noqa: F405
)
LOADERS.update({".toml": load_toml, ".json": load_json, ".py": load_py}) # noqa: F405
PARSERS: DefaultDict[str, ParserFn] = defaultdict( # noqa: F405
lambda: parse_yaml # noqa: F405
)
PARSERS.update(
{".toml": parse_toml, ".json": parse_json, ".py": parse_py} # noqa: F405
)
def load_path(fs_path, fs):
suffix = fs.path.suffix(fs_path).lower()
+2 -2
View File
@@ -177,8 +177,8 @@ def determine_linecol(
value = get(data, paths, default=None)
if value is not None:
with suppress(AttributeError, TypeError):
line = value.lc.line + 1
col = value.lc.col + 1
line = value.lc.line + 1 # type: ignore[attr-defined]
col = value.lc.col + 1 # type: ignore[attr-defined]
break
step += 1
*paths, _ = paths
+82
View File
@@ -0,0 +1,82 @@
import logging
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
from urllib.parse import urljoin
import requests
from funcy import compact, ignore
from requests.adapters import HTTPAdapter
if TYPE_CHECKING:
from requests import Response
logger = logging.getLogger(__name__)
STUDIO_URL = "https://studio.iterative.ai"
def post(
url: str,
token: str,
data: Dict[str, Any],
base_url: Optional[str] = STUDIO_URL,
max_retries: int = 3,
timeout: int = 5,
) -> "Response":
url = urljoin(base_url or STUDIO_URL, url)
session = requests.Session()
session.mount(url, HTTPAdapter(max_retries=max_retries))
logger.trace("Sending %s to %s", data, url) # type: ignore[attr-defined]
headers = {"Authorization": f"token {token}"}
r = session.post(
url, json=data, headers=headers, timeout=timeout, allow_redirects=False
)
r.raise_for_status()
return r
def notify_refs(
repo_url: str,
token: str,
*,
base_url: Optional[str] = STUDIO_URL,
**refs: List[str],
) -> Dict[str, Any]:
extra_keys = refs.keys() - {"pushed", "removed"}
assert not extra_keys, f"got extra args: {extra_keys}"
refs = compact(refs)
if not refs:
return {}
logger.debug(
"notifying Studio%s about updated experiments",
f" ({base_url})" if base_url else "",
)
data = {"repo_url": repo_url, "client": "dvc", "refs": refs}
try:
r = post("webhook/dvc", token, data, base_url=base_url)
except requests.RequestException as e:
logger.trace("", exc_info=True) # type: ignore[attr-defined]
msg = str(e)
if e.response is None:
logger.warning("failed to notify Studio: %s", msg.lower())
return {}
r = cast("Response", e.response)
d = ignore(Exception, default={})(r.json)()
status = r.status_code
if detail := d.get("detail"):
msg = f"{detail} ({status=})"
logger.warning("failed to notify Studio: %s", msg.lower())
else:
d = r.json()
if d:
logger.trace( # type: ignore[attr-defined]
"received response: %s (status=%r)", d, r.status_code
)
return d
+2 -2
View File
@@ -1,7 +1,7 @@
# pylint: disable=unused-import
try:
from ._dvc_version import version as __version__
from ._dvc_version import version_tuple
from ._dvc_version import version as __version__ # type: ignore[import]
from ._dvc_version import version_tuple # type: ignore[import]
except ImportError:
__version__ = "UNKNOWN"
version_tuple = (0, 0, __version__) # type: ignore[assignment]
+21 -14
View File
@@ -34,10 +34,10 @@ dependencies = [
"configobj>=5.0.6",
"distro>=1.3",
"dpath<3,>=2.1.0",
"dvc-data>=0.42.3,<0.43",
"dvc-http",
"dvc-render>=0.1.2",
"dvc-studio-client>=0.5.0,<1",
"dvc-data>=0.47.1,<0.48",
"dvc-http>=2.29.0",
"dvc-render>=0.3.1,<0.4.0",
"dvc-studio-client>=0.6.1,<1",
"dvc-task>=0.2.0,<1",
"flatten_dict<1,>=0.4.1",
"flufl.lock>=5",
@@ -48,7 +48,7 @@ dependencies = [
"networkx>=2.5",
"packaging>=19",
"pathspec>=0.10.3",
"platformdirs<4,>=3",
"platformdirs<4,>=3.1.1",
"psutil>=5.8",
"pydot>=1.2.4",
"pygtrie>=2.3.2",
@@ -56,7 +56,7 @@ dependencies = [
"requests>=2.22",
"rich>=12",
"ruamel.yaml>=0.17.11",
"scmrepo>=0.1.15,<1",
"scmrepo>=1.0.0,<2",
"shortuuid>=0.5",
"shtab<2,>=1.3.4",
"tabulate>=0.8.7",
@@ -69,12 +69,12 @@ dependencies = [
all = ["dvc[azure,gdrive,gs,hdfs,oss,s3,ssh,webdav,webhdfs]"]
azure = ["dvc-azure==2.21.1"]
dev = ["dvc[azure,gdrive,gs,hdfs,lint,oss,s3,ssh,terraform,tests,webdav,webhdfs]"]
gdrive = ["dvc-gdrive==2.19.1"]
gdrive = ["dvc-gdrive==2.19.2"]
gs = ["dvc-gs==2.22.0"]
hdfs = ["dvc-hdfs==2.19"]
lint = [
"mypy==1.1.1",
"pylint==2.17.0",
"mypy==1.2.0",
"pylint==2.17.2",
"types-colorama",
"types-psutil",
"types-requests",
@@ -83,9 +83,9 @@ lint = [
"types-tqdm",
]
oss = ["dvc-oss==2.19"]
s3 = ["dvc-s3==2.21"]
ssh = ["dvc-ssh==2.21"]
ssh_gssapi = ["dvc-ssh[gssapi]==2.21"]
s3 = ["dvc-s3==2.22.0"]
ssh = ["dvc-ssh>=2.22.1,<3"]
ssh_gssapi = ["dvc-ssh[gssapi]>=2.22.1,<3"]
terraform = ["tpi[ssh]>=2.1"]
testing = ["pytest-test-utils"]
tests = [
@@ -205,6 +205,7 @@ module = [
"celery.*",
"configobj.*",
"dpath.*",
"distro",
"dvc_http",
"dvc_render.*",
"dvc_ssh",
@@ -219,9 +220,12 @@ module = [
"networkx.*", # https://github.com/networkx/networkx/issues/3988
"pygtrie.*",
"pyinstrument",
"pyparsing",
"pytest_docker.plugin",
"ruamel.*",
"ruamel.yaml.*",
"shortuuid",
"shtab",
"tpi.*",
"viztracer",
"voluptuous",
@@ -253,9 +257,8 @@ ignored-argument-names = "_.*|args|kwargs"
# external flake8 codes that should be preserved
external = ["B301", "C901", "E302", "W601"]
ignore = ["N818", "S101", "PT004", "PT007", "PT019", "SIM105", "SIM108", "SIM110", "SIM117", "TRY003", "TRY200", "TRY300", "TRY301", "PLR2004", "PLW2901", "RUF005"]
select = ["F", "E", "W", "C90", "N", "UP", "YTT", "S", "BLE", "B", "A", "C4", "T10", "ISC", "ICN", "G", "INP", "PIE", "T20", "PT", "Q", "RET501", "RET504", "SIM", "TID", "TCH", "ARG", "PGH", "PLC", "PLE", "PLR", "PLW", "TRY", "RUF"]
select = ["F", "E", "W", "C90", "N", "UP", "YTT", "S", "BLE", "B", "A", "C4", "T10", "EXE", "ISC", "ICN", "G", "INP", "PIE", "T20", "PT", "Q", "RET501", "RET504", "SIM", "TID", "TCH", "ARG", "PGH", "PLC", "PLE", "PLR", "PLW", "TRY", "RUF"]
show-source = true
target-version = "py38"
[tool.ruff.flake8-pytest-style]
fixture-parentheses = false
@@ -286,3 +289,7 @@ ignore-names = ["M", "SCM"]
[tool.ruff.pylint]
max-args = 10
[tool.bandit]
exclude_dirs = ["tests", "scripts"]
skips = ["B101"]
+1 -1
View File
@@ -1,3 +1,3 @@
# NOTE: using strict version due to
# https://github.com/iterative/dvc/issues/7949
PyInstaller==5.8.0
PyInstaller==5.9.0
-15
View File
@@ -1,4 +1,3 @@
import gc
import json
import os
import sys
@@ -244,17 +243,3 @@ def run_copy_metrics(tmp_dir, copy_script):
return stage
return run
@pytest.fixture(autouse=True)
def gc_collect_on_dvc_close_on_win(mocker):
from dvc.repo import Repo
close = Repo.close
def wrapped(repo):
close(repo)
gc.collect()
if os.name == "nt":
mocker.patch("dvc.repo.Repo.close", wrapped)
+3 -7
View File
@@ -4,7 +4,7 @@ import pytest
from funcy import first, get_in
from dvc import api
from dvc.exceptions import FileMissingError, OutputNotFoundError, PathMissingError
from dvc.exceptions import OutputNotFoundError, PathMissingError
from dvc.testing.api_tests import TestAPI # noqa, pylint: disable=unused-import
from dvc.testing.tmp_dir import make_subrepo
from dvc.utils.fs import remove
@@ -75,7 +75,7 @@ def test_missing(tmp_dir, dvc, remote):
remove("foo")
with pytest.raises(FileMissingError):
with pytest.raises(PathMissingError):
api.read("foo")
@@ -100,7 +100,7 @@ def test_open_not_cached(dvc):
assert fd.read() == metric_content
os.remove(metric_file)
with pytest.raises(FileMissingError):
with pytest.raises(PathMissingError):
api.read(metric_file)
@@ -193,10 +193,6 @@ def test_get_url_subrepos(tmp_dir, scm, local_cloud):
assert api.get_url("subrepo/bar") == expected_url
@pytest.mark.xfail(
raises=PathMissingError,
reason="https://github.com/iterative/dvc/issues/7341",
)
def test_open_from_remote(tmp_dir, erepo_dir, cloud, local_cloud):
erepo_dir.add_remote(config=cloud.config, name="other")
erepo_dir.add_remote(config=local_cloud.config, default=True)
+18
View File
@@ -0,0 +1,18 @@
import pytest
from dvc import api
from dvc.repo.experiments.exceptions import ExperimentExistsError
def test_exp_save(tmp_dir, dvc, scm, mocker):
tmp_dir.scm_gen({"foo": "foo"}, commit="initial")
api.exp_save()
api.exp_save("foo")
with pytest.raises(
ExperimentExistsError,
match="Experiment conflicts with existing experiment 'foo'.",
):
api.exp_save("foo")
api.exp_save("foo", force=True)
View File
+132
View File
@@ -0,0 +1,132 @@
import os
import pytest
from dvc.annotations import Artifact
from dvc.repo.artifacts import name_is_compatible
from dvc.utils.strictyaml import YAMLSyntaxError, YAMLValidationError
dvcyaml = {
"artifacts": {
"myart": {"type": "model", "path": "myart.pkl"},
"hello": {"type": "file", "path": "hello.txt"},
"world": {
"type": "object",
"path": "world.txt",
"desc": "The world is not enough",
"labels": ["but", "this", "is"],
"meta": {"such": "a", "perfect": "place to start"},
},
}
}
def test_reading_artifacts_subdir(tmp_dir, dvc):
(tmp_dir / "dvc.yaml").dump(dvcyaml)
subdir = tmp_dir / "subdir"
subdir.mkdir()
(subdir / "dvc.yaml").dump(dvcyaml)
artifacts = {
name: Artifact(**values) for name, values in dvcyaml["artifacts"].items()
}
assert tmp_dir.dvc.artifacts.read() == {
"dvc.yaml": artifacts,
f"subdir{os.path.sep}dvc.yaml": artifacts,
}
bad_dvcyaml_extra_field = {
"artifacts": {
"lol": {"kek": "cheburek", "path": "lol"},
"hello": {"type": "file", "path": "hello.txt"},
}
}
bad_dvcyaml_missing_path = {
"artifacts": {
"lol": {},
}
}
@pytest.mark.parametrize(
"bad_dvcyaml", [bad_dvcyaml_extra_field, bad_dvcyaml_missing_path]
)
def test_broken_dvcyaml_extra_field(tmp_dir, dvc, bad_dvcyaml):
(tmp_dir / "dvc.yaml").dump(bad_dvcyaml)
with pytest.raises(YAMLValidationError):
tmp_dir.dvc.artifacts.read()
bad_dvcyaml_id_duplication = """
artifacts:
lol:
type: kek
lol: {}
"""
def test_broken_dvcyaml_id_duplication(tmp_dir, dvc):
with open(tmp_dir / "dvc.yaml", "w") as f:
f.write(bad_dvcyaml_id_duplication)
with pytest.raises(YAMLSyntaxError):
tmp_dir.dvc.artifacts.read()
dvcyaml_redirecting = {"artifacts": "artifacts.yaml"}
def test_read_artifacts_yaml(tmp_dir, dvc):
(tmp_dir / "dvc.yaml").dump(dvcyaml_redirecting)
(tmp_dir / "artifacts.yaml").dump(dvcyaml["artifacts"])
artifacts = {
name: Artifact(**values) for name, values in dvcyaml["artifacts"].items()
}
assert tmp_dir.dvc.artifacts.read() == {
"dvc.yaml": artifacts,
}
@pytest.mark.parametrize(
"name",
[
"m",
"nn",
"m1",
"model-prod",
"model-prod-v1",
],
)
def test_check_name_is_valid(name):
assert name_is_compatible(name)
@pytest.mark.parametrize(
"name",
[
"",
"1",
"m/",
"/m",
"1nn",
"###",
"@@@",
"a model",
"a_model",
"-model",
"model-",
"model@1",
"model#1",
"@namespace/model",
"namespace/model",
],
)
def test_check_name_is_invalid(name):
assert not name_is_compatible(name)
+8 -1
View File
@@ -1,3 +1,5 @@
import os
import pytest
from funcy import first
@@ -26,6 +28,11 @@ def test_apply(tmp_dir, scm, dvc, exp_stage):
assert (tmp_dir / "metrics.yaml").read_text().strip() == "foo: 3"
@pytest.mark.xfail(
os.name == "nt",
strict=False,
reason="https://github.com/iterative/dvc/issues/9143",
)
def test_apply_failed(tmp_dir, scm, dvc, failed_exp_stage, session_queue):
from dvc.exceptions import InvalidArgumentError
@@ -35,7 +42,7 @@ def test_apply_failed(tmp_dir, scm, dvc, failed_exp_stage, session_queue):
dvc.experiments.run(
failed_exp_stage.addressing, params=["foo=3"], queue=True, name="foo"
)
dvc.experiments.run(run_all=True)
session_queue.wait(["foo"])
with pytest.raises(InvalidArgumentError):
dvc.experiments.apply("bar")
+5 -8
View File
@@ -255,17 +255,14 @@ def test_auto_push_self_remote(
def test_tmp_dir_failed_checkpoint(tmp_dir, scm, dvc, failed_checkpoint_stage, caplog):
baseline = scm.get_rev()
results = dvc.experiments.run(
dvc.experiments.run(
failed_checkpoint_stage.addressing, params=["foo=2"], tmp_dir=True
)
exp = first(results)
result = dvc.experiments.show()[baseline]
# Assert 4 rows: baseline, 2 checkpoints, and final commit
assert len(result) == 4
assert exp in result
result = dvc.experiments.show()[1]
assert len(result.experiments) == 1
# Assert 2 checkpoints and final commit
assert len(result.experiments[0]) == 3
assert (
"Checkpoint stage 'failed-checkpoint-file' was interrupted remaining "
"stages in pipeline will not be reproduced." in caplog.text
-10
View File
@@ -1,16 +1,6 @@
from funcy import first
def test_diff_no_commits(tmp_dir):
from dvc.repo import Repo
from dvc.scm import Git
git = Git.init(tmp_dir.fs_path)
assert git.no_commits
assert Repo.init().experiments.diff() == {}
def test_diff_empty(tmp_dir, scm, dvc, exp_stage):
assert dvc.experiments.diff() == {"params": {}, "metrics": {}}
+140 -8
View File
@@ -5,15 +5,16 @@ import stat
from textwrap import dedent
import pytest
from configobj import ConfigObj
from funcy import first
from dvc.dvcfile import PROJECT_FILE
from dvc.exceptions import ReproductionError
from dvc.exceptions import DvcException, ReproductionError
from dvc.repo.experiments.exceptions import ExperimentExistsError
from dvc.repo.experiments.queue.base import BaseStashQueue
from dvc.repo.experiments.refs import CELERY_STASH
from dvc.repo.experiments.utils import exp_refs_by_rev
from dvc.scm import resolve_rev
from dvc.scm import SCMError, resolve_rev
from dvc.stage.exceptions import StageFileDoesNotExistError
from dvc.utils.serialize import PythonFileCorruptedError
from tests.scripts import COPY_SCRIPT
@@ -246,11 +247,12 @@ def test_branch(tmp_dir, scm, dvc, exp_stage):
with pytest.raises(InvalidArgumentError):
dvc.experiments.branch("foo", "branch-exists")
dvc.experiments.branch("foo")
dvc.experiments.branch("foo", "branch-name")
dvc.experiments.branch(exp_a, "branch-rev")
dvc.experiments.branch(ref_a, "branch-ref")
for name in ["branch-name", "branch-rev", "branch-ref"]:
for name in ["foo-branch", "branch-name", "branch-rev", "branch-ref"]:
assert name in scm.list_branches()
assert scm.resolve_rev(name) == exp_a
@@ -328,11 +330,13 @@ def test_packed_args_exists(tmp_dir, scm, dvc, exp_stage, caplog):
tmp_dir / ".dvc" / "tmp" / BaseExecutor.PACKED_ARGS_FILE,
"",
commit="commit args file",
force=True,
)
with caplog.at_level(logging.WARNING):
dvc.experiments.run(exp_stage.addressing)
assert "Temporary DVC file" in caplog.text
assert not (tmp_dir / ".dvc" / "tmp" / BaseExecutor.PACKED_ARGS_FILE).exists()
def test_list(tmp_dir, scm, dvc, exp_stage):
@@ -597,17 +601,21 @@ def test_experiments_workspace_not_log_exception(caplog, dvc, scm):
assert not caplog.text
@pytest.mark.vscode
def test_run_env(tmp_dir, dvc, scm, mocker):
dump_run_env = dedent(
"""\
import os
from dvc_studio_client.env import STUDIO_REPO_URL
from dvc.env import DVC_EXP_BASELINE_REV, DVC_EXP_NAME
with open("DVC_EXP_BASELINE_REV", "w") as f:
f.write(os.environ.get(DVC_EXP_BASELINE_REV, ""))
with open("DVC_EXP_NAME", "w") as f:
f.write(os.environ.get(DVC_EXP_NAME, ""))
for v in (DVC_EXP_BASELINE_REV, DVC_EXP_NAME, STUDIO_REPO_URL):
with open(v, "w") as f:
f.write(os.environ.get(v, ""))
"""
)
mocker.patch(
"dvc.repo.experiments.queue.base.get_studio_repo_url", return_value="REPO_URL"
)
(tmp_dir / "dump_run_env.py").write_text(dump_run_env)
baseline = scm.get_rev()
dvc.stage.add(
@@ -616,11 +624,13 @@ def test_run_env(tmp_dir, dvc, scm, mocker):
)
dvc.experiments.run()
assert (tmp_dir / "DVC_EXP_BASELINE_REV").read_text().strip() == baseline
assert (tmp_dir / "DVC_EXP_NAME").read_text().strip() != ""
assert (tmp_dir / "DVC_EXP_NAME").read_text().strip()
assert (tmp_dir / "STUDIO_REPO_URL").read_text().strip() == "REPO_URL"
dvc.experiments.run(name="foo")
assert (tmp_dir / "DVC_EXP_BASELINE_REV").read_text().strip() == baseline
assert (tmp_dir / "DVC_EXP_NAME").read_text().strip() == "foo"
assert (tmp_dir / "STUDIO_REPO_URL").read_text().strip() == "REPO_URL"
def test_experiment_unchanged(tmp_dir, scm, dvc, exp_stage):
@@ -640,3 +650,125 @@ def test_clean(tmp_dir, scm, dvc, mocker):
clean = mocker.spy(dvc.experiments.celery_queue.celery, "clean")
dvc.experiments.clean()
clean.assert_called_once_with()
def test_experiment_no_commit(tmp_dir):
from scmrepo.git import Git
from dvc.repo import Repo
Git.init(tmp_dir.fs_path).close()
repo = Repo.init()
assert repo.scm.no_commits
try:
with pytest.raises(SCMError): # noqa: PT011
repo.experiments.ls()
finally:
repo.close()
def test_local_config_is_propagated_to_tmp(tmp_dir, scm, dvc):
with dvc.config.edit("local") as conf:
conf["cache"]["type"] = "hardlink"
stage = dvc.stage.add(
cmd="cat .dvc/config.local > file", name="foo", outs_no_cache=["file"]
)
scm.add_commit(["dvc.yaml"], message="add dvc.yaml")
results = dvc.experiments.run(stage.addressing, tmp_dir=True)
exp = first(results)
fs = scm.get_fs(exp)
with fs.open("file") as fobj:
conf_obj = ConfigObj(fobj)
assert conf_obj["cache"]["type"] == "hardlink"
@pytest.mark.parametrize("tmp", [True, False])
def test_untracked_top_level_files_are_included_in_exp(tmp_dir, scm, dvc, tmp):
(tmp_dir / "dvc.yaml").dump(
{
"metrics": ["metrics.json"],
"params": ["params.yaml"],
"plots": ["plots.csv"],
}
)
stage = dvc.stage.add(
cmd="touch metrics.json && touch params.yaml && touch plots.csv",
name="top-level",
)
scm.add_commit(["dvc.yaml"], message="add dvc.yaml")
results = dvc.experiments.run(stage.addressing, tmp_dir=tmp)
exp = first(results)
fs = scm.get_fs(exp)
for file in ["metrics.json", "params.yaml", "plots.csv"]:
assert fs.exists(file)
@pytest.mark.parametrize("tmp", [True, False])
def test_copy_paths(tmp_dir, scm, dvc, tmp):
stage = dvc.stage.add(
cmd="cat file && ls dir",
name="foo",
)
scm.add_commit(["dvc.yaml"], message="add dvc.yaml")
(tmp_dir / "dir").mkdir()
(tmp_dir / "dir" / "file").write_text("dir/file")
scm.ignore(tmp_dir / "dir")
(tmp_dir / "file").write_text("file")
scm.ignore(tmp_dir / "file")
results = dvc.experiments.run(
stage.addressing, tmp_dir=tmp, copy_paths=["dir", "file"]
)
exp = first(results)
fs = scm.get_fs(exp)
assert not fs.exists("dir")
assert not fs.exists("file")
def test_copy_paths_errors(tmp_dir, scm, dvc, mocker):
stage = dvc.stage.add(
cmd="echo foo",
name="foo",
)
scm.add_commit(["dvc.yaml"], message="add dvc.yaml")
with pytest.raises(DvcException, match="Unable to copy"):
dvc.experiments.run(stage.addressing, tmp_dir=True, copy_paths=["foo"])
(tmp_dir / "foo").write_text("foo")
mocker.patch("shutil.copy", side_effect=OSError)
with pytest.raises(DvcException, match="Unable to copy"):
dvc.experiments.run(stage.addressing, tmp_dir=True, copy_paths=["foo"])
def test_mixed_git_dvc_out(tmp_dir, scm, dvc, exp_stage):
(tmp_dir / "dir").mkdir()
dir_metrics = os.path.join("dir", "metrics.yaml")
dvc.stage.add(
cmd=f"python copy.py params.yaml {dir_metrics}",
metrics=[dir_metrics],
params=["foo"],
name="copy-file",
deps=["copy.py"],
force=True,
)
dvc.stage.add(
cmd=f"python copy.py {dir_metrics} metrics.yaml",
metrics_no_cache=["metrics.yaml"],
name="copy-dir-file",
deps=["dir"],
)
scm.add(["dvc.yaml", "dvc.lock"])
scm.commit("add dir stage")
exp = first(dvc.experiments.run())
assert (tmp_dir / "dir" / "metrics.yaml").exists()
git_fs = scm.get_fs(exp)
assert not git_fs.exists("dir/metrics.yaml")
+5 -5
View File
@@ -166,7 +166,7 @@ def test_when_params_is_omitted_in_interactive_mode(tmp_dir, scm, dvc):
}
}
assert not (tmp_dir / "dvc.lock").exists()
assert (tmp_dir / "script.py").read_text() == ""
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
scm._reset()
assert scm.is_tracked("dvc.yaml")
@@ -194,7 +194,7 @@ def test_init_interactive_params_validation(tmp_dir, dvc, capsys):
}
}
}
assert (tmp_dir / "script.py").read_text() == ""
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
out, err = capsys.readouterr()
@@ -226,7 +226,7 @@ def test_init_with_no_defaults_interactive(tmp_dir, dvc):
}
}
}
assert (tmp_dir / "script.py").read_text() == ""
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
@@ -275,7 +275,7 @@ def test_init_default(tmp_dir, scm, dvc, interactive, overrides, inp, capsys):
}
}
assert not (tmp_dir / "dvc.lock").exists()
assert (tmp_dir / "script.py").read_text() == ""
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
scm._reset()
assert scm.is_tracked("dvc.yaml")
@@ -345,7 +345,7 @@ def test_init_interactive_live(tmp_dir, scm, dvc, interactive, overrides, inp, c
}
}
assert not (tmp_dir / "dvc.lock").exists()
assert (tmp_dir / "script.py").read_text() == ""
assert not (tmp_dir / "script.py").read_text()
assert (tmp_dir / "data").is_dir()
scm._reset()
assert scm.is_tracked("dvc.yaml")
+41 -1
View File
@@ -22,8 +22,9 @@ def test_celery_logs(
test_queue,
):
celery_queue = dvc.experiments.celery_queue
dvc.experiments.run(failed_exp_stage.addressing, queue=True)
dvc.experiments.run(failed_exp_stage.addressing, queue=True, name="foo")
dvc.experiments.run(run_all=True)
test_queue.wait(["foo"])
done_result = first(celery_queue.iter_done())
@@ -63,6 +64,7 @@ def test_queue_remove_done(
name=name_fail,
)
dvc.experiments.run(run_all=True)
test_queue.wait(success_tasks + failed_tasks)
assert len(celery_queue.failed_stash) == 3
status = to_dict(celery_queue.status())
assert len(status) == 6
@@ -90,3 +92,41 @@ def test_queue_remove_done(
assert set(celery_queue.clear(success=True)) == set(success_tasks[:2])
assert celery_queue.status() == []
def test_queue_doesnt_remove_untracked_params_file(tmp_dir, dvc, scm):
"""Regression test for https://github.com/iterative/dvc/issues/7842"""
tmp_dir.gen("params.yaml", "foo: 1")
stage = dvc.run(cmd="echo ${foo}", params=["foo"], name="echo-foo")
scm.add(
[
"dvc.yaml",
"dvc.lock",
".gitignore",
]
)
scm.commit("init")
dvc.experiments.run(stage.addressing, params=["foo=2"], queue=True)
assert (tmp_dir / "params.yaml").exists()
def test_copy_paths_queue(tmp_dir, scm, dvc):
stage = dvc.stage.add(
cmd="cat file && ls dir",
name="foo",
)
scm.add_commit(["dvc.yaml"], message="add dvc.yaml")
(tmp_dir / "dir").mkdir()
(tmp_dir / "dir" / "file").write_text("dir/file")
scm.ignore(tmp_dir / "dir")
(tmp_dir / "file").write_text("file")
scm.ignore(tmp_dir / "file")
dvc.experiments.run(stage.addressing, queue=True)
results = dvc.experiments.run(run_all=True)
exp = first(results)
fs = scm.get_fs(exp)
assert not fs.exists("dir")
assert not fs.exists("file")
+5 -1
View File
@@ -76,7 +76,11 @@ def test_push_diverged(tmp_dir, scm, dvc, git_upstream, exp_stage):
git_upstream.tmp_dir.scm.set_ref(str(ref_info), remote_rev)
assert dvc.experiments.push(git_upstream.remote, [ref_info.name]) == []
assert dvc.experiments.push(git_upstream.remote, [ref_info.name]) == {
"diverged": [ref_info.name],
"url": None,
"uploaded": 0,
}
assert git_upstream.tmp_dir.scm.get_ref(str(ref_info)) == remote_rev
dvc.experiments.push(git_upstream.remote, [ref_info.name], force=True)
+6
View File
@@ -2,6 +2,7 @@ import pytest
from funcy import first
from dvc.exceptions import InvalidArgumentError
from dvc.repo.experiments.exceptions import UnresolvedExpNamesError
from dvc.repo.experiments.utils import exp_refs_by_rev
@@ -121,6 +122,11 @@ def test_remove_remote(tmp_dir, scm, dvc, exp_stage, git_upstream, use_url):
assert git_upstream.tmp_dir.scm.get_ref(str(ref_info_list[1])) is None
assert git_upstream.tmp_dir.scm.get_ref(str(ref_info_list[2])) == exp_list[2]
with pytest.raises(
UnresolvedExpNamesError, match=f"Experiment 'foo' does not exist in '{remote}'"
):
dvc.experiments.remove(git_remote=remote, exp_names=["foo"])
def test_remove_experiments_by_rev(tmp_dir, scm, dvc, exp_stage):
baseline = scm.get_rev()

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