Compare commits

...
96 Commits
Author SHA1 Message Date
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
Ruslan Kuprieiev 17f523bf86 deps: bump dvc-data to >=0.42.3 2023-03-11 02:01:31 +02:00
Ruslan Kuprieiev a0bf2d997a repo: data_index: fallback to in-memory
Makes for a much simpler resource management elsewhere.
2023-03-11 01:23:12 +02:00
Ruslan Kuprieiev 8ed9b911db deps: bump dvc-data to >= 0.42.2 2023-03-10 22:40:59 +02:00
skshetry ef22b1d77e plots: disable syntax highlighting on plots json output (#9155)
I don't think it's worth it to highlight given the size of json output.
2023-03-11 00:50:06 +05:45
skshetry aaa825a9a8 config: ignore unspecified keys from feature section (#9153)
A warning is shown to the user for the unsupported/ignored keys, and then ignored.
The warning is raised only once per interpreter session.
2023-03-10 21:52:05 +05:45
Ivan Shcheklein 2a40a55bb4 fix(exp): cleanup help messages 2023-03-10 11:13:06 +09:00
Ruslan Kuprieiev 3aa9f51e36 output: suppress cloud.pull errors on cache collection
Fixes https://github.com/iterative/studio/issues/5261
2023-03-09 22:44:32 +02:00
dependabot[bot] 1cfdc22549 build(deps-dev): bump pylint from 2.16.4 to 2.17.0 (#9141)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.16.4 to 2.17.0.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.16.4...v2.17.0)

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

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

* dvc.cli: cache get_main_parser()

* tests: isolate environment on a session-level

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

* run-cache: avoid leaking temp files on Windows

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

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

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

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-07 10:17:04 +05:45
126 changed files with 1674 additions and 1243 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
+2 -2
View File
@@ -43,11 +43,11 @@ repos:
hooks:
- id: isort
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.254'
rev: 'v0.0.257'
hooks:
- id: ruff
- repo: https://github.com/codespell-project/codespell
rev: v2.2.2
rev: v2.2.4
hooks:
- id: codespell
additional_dependencies: ["tomli"]
+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")
+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 -14
View File
@@ -1,12 +1,30 @@
from contextlib import _GeneratorContextManager as GCM
from contextlib import contextmanager
from typing import 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):
@@ -195,10 +215,42 @@ 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
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:
if remote:
remote_odb = _repo.cloud.get_remote_odb(name=remote)
oid = fs.info(fs_path)["dvc_info"]["md5"]
fs = remote_odb.fs
fs_path = remote_odb.oid_to_path(oid)
with fs.open(
fs_path,
mode=mode,
encoding=encoding,
) 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
)
+2
View File
@@ -2,6 +2,7 @@
import argparse
import logging
import os
from functools import lru_cache
from dvc import __version__
from dvc.commands import (
@@ -150,6 +151,7 @@ def get_parent_parser():
return parent_parser
@lru_cache(maxsize=1)
def get_main_parser():
parent_parser = get_parent_parser()
+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
@@ -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)
+1 -1
View File
@@ -14,7 +14,7 @@ class CmdExperimentsClean(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_CLEAN_HELP = "Cleanup dvc exp internal tempfiles."
EXPERIMENTS_CLEAN_HELP = "Cleanup experiments temporary internal files."
experiments_clean_parser = experiments_subparsers.add_parser(
"clean",
parents=[parent_parser],
+1 -1
View File
@@ -52,7 +52,7 @@ class CmdExperimentsDiff(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_DIFF_HELP = "Show changes between experiments in the DVC repository."
EXPERIMENTS_DIFF_HELP = "Show changes between experiments."
experiments_diff_parser = experiments_subparsers.add_parser(
"diff",
+2 -1
View File
@@ -78,7 +78,8 @@ class CmdExperimentsGC(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_GC_HELP = "Garbage collect unneeded experiments."
EXPERIMENTS_GC_DESCRIPTION = (
"Removes all experiments which are not derived from the specifiedGit revisions."
"Removes all experiments which are not derived"
" from the specified Git revisions."
)
experiments_gc_parser = experiments_subparsers.add_parser(
"gc",
+10 -5
View File
@@ -19,9 +19,11 @@ class CmdExperimentsPush(CmdBase):
)
def run(self):
from dvc.utils import humanize
self.raise_error_if_all_disabled()
pushed_exps = self.repo.experiments.push(
result = self.repo.experiments.push(
self.args.git_remote,
self.args.experiment,
all_commits=self.args.all_commits,
@@ -33,14 +35,17 @@ class CmdExperimentsPush(CmdBase):
jobs=self.args.jobs,
run_cache=self.args.run_cache,
)
if pushed_exps:
if pushed_exps := result.get("pushed"):
ui.write(
f"Pushed experiment '{pushed_exps}'"
f"to Git remote '{self.args.git_remote}'."
f"Pushed experiment {humanize.join(map(repr, pushed_exps))} "
f"to Git remote {self.args.git_remote!r}."
)
else:
ui.write("No experiments to push.")
if project_url := result.get("url"):
ui.write("[yellow]View your experiments at", project_url, styled=True)
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
+1 -1
View File
@@ -35,7 +35,7 @@ class CmdExperimentsSave(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_SAVE_HELP = "Save current workspace as a dvc experiment."
EXPERIMENTS_SAVE_HELP = "Save current workspace as an experiment."
save_parser = experiments_subparsers.add_parser(
"save",
parents=[parent_parser],
+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
+54 -11
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)
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
@@ -112,7 +149,7 @@ class CmdPlots(CmdBase):
props=self._props(),
)
if not plots_data:
if not plots_data and not self.args.json:
ui.error_write(
"No plots were loaded, visualization file will not be created."
)
@@ -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"
+13 -9
View File
@@ -103,21 +103,25 @@ 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 appdirs import site_config_dir, user_config_dir
from platformdirs import site_config_dir, user_config_dir
assert level in ("global", "system")
+49 -12
View File
@@ -1,8 +1,21 @@
import logging
import os
from urllib.parse import urlparse
from funcy import walk_values
from voluptuous import All, Any, Coerce, Invalid, Lower, Optional, Range, Schema
from funcy import once, walk_values
from voluptuous import (
REMOVE_EXTRA,
All,
Any,
Coerce,
Invalid,
Lower,
Optional,
Range,
Schema,
)
logger = logging.getLogger(__name__)
Bool = All(
Lower,
@@ -49,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}://")
@@ -65,6 +78,27 @@ class RelPath(str):
pass
class FeatureSchema(Schema):
def __init__(self, schema, required=False):
super().__init__(schema, required=required, extra=REMOVE_EXTRA)
@staticmethod
@once
def _log_deprecated(keys):
# only run this once per session
message = "%s config option%s unsupported"
paths = ", ".join(f"'feature.{key}'" for key in keys)
pluralize = " is" if len(keys) == 1 else "s are"
logger.warning(message, paths, pluralize)
def __call__(self, data):
ret = super().__call__(data)
extra_keys = data.keys() - ret.keys()
if extra_keys:
self._log_deprecated(sorted(extra_keys))
return ret
REMOTE_COMMON = {
"url": str,
"checksum_jobs": All(Coerce(int), Range(1)),
@@ -239,12 +273,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: {
@@ -262,12 +296,15 @@ SCHEMA = {
},
},
# section for experimental features
"feature": {
Optional("machine", default=False): Bool,
# enabled by default. It's of no use, kept for backward compatibility.
Optional("data_index_cache", default=False): Bool,
Optional("parametrization", default=True): Bool,
},
# only specified keys are validated, others get logged and then ignored/removed
"feature": FeatureSchema(
{
Optional("machine", default=False): Bool,
Optional("push_exp_to_studio", default=False): Bool,
"studio_token": str,
"studio_url": str,
},
),
"plots": {
"html_template": str,
Optional("auto_open", default=False): Bool,
+20 -2
View File
@@ -14,7 +14,14 @@ from dvc.utils import fix_env, is_binary
logger = logging.getLogger(__name__)
def _popen(cmd, **kwargs):
def _suppress_resource_warning(popen: Popen):
"""Sets the returncode to avoid ResourceWarning when popen is garbage collected."""
# only use for daemon processes.
# See https://bugs.python.org/issue38890.
popen.returncode = 0
def _popen(cmd, **kwargs) -> Popen:
prefix = [sys.executable]
if not is_binary():
main_entrypoint = os.path.join(
@@ -40,12 +47,22 @@ def _spawn_windows(cmd, env):
startupinfo = STARTUPINFO()
startupinfo.dwFlags |= STARTF_USESHOWWINDOW
_popen(cmd, env=env, creationflags=creationflags, startupinfo=startupinfo)
popen = _popen(
cmd, env=env, creationflags=creationflags, startupinfo=startupinfo
)
_suppress_resource_warning(popen)
def _spawn_posix(cmd, env):
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__"
@@ -72,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 -4
View File
@@ -1,3 +1,4 @@
import errno
import os
from collections import defaultdict
from copy import copy
@@ -36,11 +37,19 @@ class RepoDependency(Dependency):
}
def __init__(self, def_repo: Dict[str, str], stage: "Stage", *args, **kwargs):
from dvc.fs import DVCFileSystem
self.def_repo = def_repo
self._objs: Dict[str, "HashFile"] = {}
self._meta: Dict[str, "Meta"] = {}
super().__init__(stage, *args, **kwargs)
self.fs = DVCFileSystem(
url=self.def_repo[self.PARAM_URL],
rev=self._get_rev(),
)
self.fs_path = self.def_path
def _parse_path(self, fs, fs_path): # noqa: ARG002
return None
@@ -106,7 +115,7 @@ class RepoDependency(Dependency):
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.exceptions import NoOutputOrStageError
from dvc.utils import as_posix
from dvc_data.hashfile.build import build
from dvc_data.hashfile.tree import Tree, TreeError
@@ -143,8 +152,10 @@ class RepoDependency(Dependency):
local_odb.fs.PARAM_CHECKSUM,
)
except (FileNotFoundError, TreeError) as exc:
raise PathMissingError(
self.def_path, self.def_repo[self.PARAM_URL]
raise FileNotFoundError(
errno.ENOENT,
os.strerror(errno.ENOENT) + f" in {self.def_repo[self.PARAM_URL]}",
self.def_path,
) from exc
object_store = copy(object_store)
object_store.read_only = True
@@ -215,7 +226,13 @@ class RepoDependency(Dependency):
d = self.def_repo
rev = self._get_rev(locked=locked)
return external_repo(d[self.PARAM_URL], rev=rev, **kwargs)
return external_repo(
d[self.PARAM_URL],
rev=rev,
subrepos=True,
uninitialized=True,
**kwargs,
)
def _get_rev(self, locked: bool = True):
d = self.def_repo
+1 -1
View File
@@ -366,7 +366,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)
+9 -4
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
+8 -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
@@ -55,43 +44,25 @@ def external_repo(
config = _get_remote_config(url) if os.path.isdir(url) else {}
config.update(cache_config)
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:
+1 -1
View File
@@ -121,7 +121,7 @@ def _get_cloud_fs(repo_config, **kwargs):
remote_conf = get_fs_config(repo_config, **kwargs)
try:
remote_conf = SCHEMA["remote"][str](remote_conf)
remote_conf = SCHEMA["remote"][str](remote_conf) # type: ignore[index]
except Invalid as exc:
raise RepoConfigError(str(exc)) from None
+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
+4 -2
View File
@@ -2,6 +2,7 @@ import logging
import os
import posixpath
from collections import defaultdict
from contextlib import suppress
from operator import itemgetter
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type
from urllib.parse import urlparse
@@ -426,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
@@ -1017,7 +1018,8 @@ class Output:
except FileNotFoundError:
if self.remote:
kwargs["remote"] = self.remote
self.repo.cloud.pull([obj.hash_info], **kwargs)
with suppress(Exception):
self.repo.cloud.pull([obj.hash_info], **kwargs)
if self.obj:
return self.obj
+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)):
+1 -1
View File
@@ -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():
View File
-23
View File
@@ -1,23 +0,0 @@
from dvc.exceptions import DvcException
class ProcessNotTerminatedError(DvcException):
def __init__(self, name):
super().__init__(f"Managed process '{name}' has not been terminated.")
class ProcessNotFoundError(DvcException):
def __init__(self, name):
super().__init__(f"Managed process '{name}' does not exist.")
class TimeoutExpired(DvcException):
def __init__(self, cmd, timeout):
super().__init__(f"'{cmd}' did not complete before timeout '{timeout}'")
self.cmd = cmd
self.timeout = timeout
class UnsupportedSignalError(DvcException):
def __init__(self, sig):
super().__init__(f"Unsupported signal: {sig}")
-149
View File
@@ -1,149 +0,0 @@
"""Serverless process manager."""
import json
import logging
import os
import signal
import sys
from typing import Generator, List, Optional, Tuple, Union
from funcy.flow import reraise
from shortuuid import uuid
from .exceptions import ProcessNotTerminatedError, UnsupportedSignalError
from .process import ManagedProcess, ProcessInfo
logger = logging.getLogger(__name__)
class ProcessManager:
"""Manager for controlling background ManagedProcess(es).
Spawned process entries are kept in the manager directory until they
are explicitly removed (with remove() or cleanup()) so that return
value and log information can be accessed after a process has completed.
"""
def __init__(self, wdir: Optional[str] = None):
self.wdir = wdir or "."
def __iter__(self) -> Generator[str, None, None]:
if not os.path.exists(self.wdir):
return
yield from os.listdir(self.wdir)
def __getitem__(self, key: str) -> "ProcessInfo":
info_path = os.path.join(self.wdir, key, f"{key}.json")
try:
with open(info_path, encoding="utf-8") as fobj:
return ProcessInfo.from_dict(json.load(fobj))
except FileNotFoundError:
raise KeyError # noqa: B904
@reraise(FileNotFoundError, KeyError)
def __setitem__(self, key: str, value: "ProcessInfo"):
info_path = os.path.join(self.wdir, key, f"{key}.json")
with open(info_path, "w", encoding="utf-8") as fobj:
return json.dump(value.asdict(), fobj)
def __delitem__(self, key: str) -> None:
from dvc.utils.fs import remove
path = os.path.join(self.wdir, key)
if os.path.exists(path):
remove(path)
def get(self, key: str, default=None):
try:
return self[key]
except KeyError:
return default
def processes(self) -> Generator[Tuple[str, "ProcessInfo"], None, None]:
for name in self:
try:
yield name, self[name]
except KeyError:
continue
def spawn(self, args: Union[str, List[str]], name: Optional[str] = None):
"""Run the given command in the background."""
name = name or uuid()
pid = ManagedProcess.spawn(
args,
wdir=os.path.join(self.wdir, name),
name=name,
)
logger.debug(
"Spawned managed process '%s' (PID: '%d')",
name,
pid,
)
def send_signal(self, name: str, sig: int):
"""Send `signal` to the specified named process."""
process_info = self[name]
if sys.platform == "win32" and sig not in (
signal.SIGTERM,
signal.CTRL_C_EVENT, # pylint: disable=no-member
signal.CTRL_BREAK_EVENT, # pylint: disable=no-member
):
raise UnsupportedSignalError(sig)
def handle_closed_process():
logging.warning("Process %s had already aborted unexpectedly.", name)
process_info.returncode = -1
self[name] = process_info
if process_info.returncode is None:
try:
os.kill(process_info.pid, sig)
except ProcessLookupError:
handle_closed_process()
raise
except OSError as exc:
if getattr(exc, "winerror", None) == 87:
handle_closed_process()
raise ProcessLookupError from exc
raise
def terminate(self, name: str):
"""Terminate the specified named process."""
self.send_signal(name, signal.SIGTERM)
def kill(self, name: str):
"""Kill the specified named process."""
if sys.platform == "win32":
self.send_signal(name, signal.SIGTERM)
else:
self.send_signal(name, signal.SIGKILL) # pylint: disable=no-member
def remove(self, name: str, force: bool = False):
"""Remove the specified named process from this manager.
If the specified process is still running, it will be forcefully killed
if `force` is True`, otherwise an exception will be raised.
Raises:
ProcessNotTerminatedError if the specified process is still
running and was not forcefully killed.
"""
try:
process_info = self[name]
except KeyError:
return
if process_info.returncode is None and not force:
raise ProcessNotTerminatedError(name)
try:
self.kill(name)
except ProcessLookupError:
pass
del self[name]
def cleanup(self, force: bool = False):
"""Remove stale (terminated) processes from this manager."""
for name in self:
try:
self.remove(name, force)
except ProcessNotTerminatedError:
continue
-179
View File
@@ -1,179 +0,0 @@
import json
import logging
import os
import shlex
import subprocess
from contextlib import AbstractContextManager
from dataclasses import asdict, dataclass
from typing import BinaryIO, List, Optional, Union
from shortuuid import uuid
from dvc.utils.objects import cached_property
from .exceptions import TimeoutExpired
logger = logging.getLogger(__name__)
@dataclass
class ProcessInfo:
pid: int
stdin: Optional[str]
stdout: Optional[str]
stderr: Optional[str]
returncode: Optional[int]
@classmethod
def from_dict(cls, d):
return cls(**d)
def asdict(self):
return asdict(self)
class ManagedProcess(AbstractContextManager):
"""Run the specified command with redirected output.
stdout and stderr will both be redirected to <name>.out.
Interactive processes (requiring stdin input) are currently unsupported.
Parameters:
args: Command to be run.
wdir: If specified, redirected output files will be placed in `wdir`.
name: Name to use for this process, if not specified a UUID will be
generated instead.
"""
def __init__(
self,
args: Union[str, List[str]],
wdir: Optional[str] = None,
name: Optional[str] = None,
):
self.args: List[str] = (
shlex.split(args, posix=os.name == "posix")
if isinstance(args, str)
else list(args)
)
self.wdir = wdir
self.name = name or uuid()
self.returncode: Optional[int] = None
self._stdout: Optional[BinaryIO] = None
self._stderr: Optional[BinaryIO] = None
self._run()
def __exit__(self, exc_type, exc_value, traceback):
self.wait()
def _close_fds(self):
if self._stdout:
self._stdout.close()
self._stdout = None
if self._stderr:
self._stderr.close()
self._stderr = None
def _make_path(self, path: str) -> str:
return os.path.join(self.wdir, path) if self.wdir else path
@cached_property
def stdout_path(self) -> str:
return self._make_path(f"{self.name}.out")
@cached_property
def info_path(self) -> str:
return self._make_path(f"{self.name}.json")
@cached_property
def pidfile_path(self) -> str:
return self._make_path(f"{self.name}.pid")
@property
def info(self) -> "ProcessInfo":
return ProcessInfo(
pid=self.pid,
stdin=None,
stdout=self.stdout_path,
stderr=None,
returncode=self.returncode,
)
def _make_wdir(self):
if self.wdir:
os.makedirs(self.wdir, exist_ok=True)
def _dump(self):
self._make_wdir()
with open(self.info_path, "w", encoding="utf-8") as fobj:
json.dump(self.info.asdict(), fobj)
with open(self.pidfile_path, "w", encoding="utf-8") as fobj:
fobj.write(str(self.pid))
def _run(self) -> None:
self._make_wdir()
logger.debug(
"Appending output to '%s'",
self.stdout_path,
)
self._stdout = open(self.stdout_path, "ab") # noqa: SIM115
try:
self._proc = subprocess.Popen(
self.args,
stdin=subprocess.DEVNULL,
stdout=self._stdout,
stderr=subprocess.STDOUT,
close_fds=True,
shell=False,
)
self.pid: int = self._proc.pid
self._dump()
except Exception:
if self._proc is not None:
self._proc.kill()
self._close_fds()
raise
def wait(self, timeout: Optional[int] = None) -> Optional[int]:
"""Block until a process started with `run` has completed.
Raises:
TimeoutExpired if `timeout` was set and the process
did not terminate after `timeout` seconds.
"""
if self.returncode is not None:
return self.returncode
try:
self._proc.wait(timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise TimeoutExpired(exc.cmd, exc.timeout) from exc
self.returncode = self._proc.returncode
self._close_fds()
self._dump()
return self.returncode
@classmethod
def spawn(cls, *args, **kwargs) -> Optional[int]:
"""Spawn a ManagedProcess command in the background.
Returns: The spawned process PID.
"""
import multiprocessing as mp
proc = mp.Process(
target=cls._spawn,
args=args,
kwargs=kwargs,
daemon=True,
)
proc.start()
# Do not terminate the child daemon when the main process exits
# pylint: disable=protected-access
mp.process._children.discard(proc) # type: ignore[attr-defined]
return proc.pid
@classmethod
def _spawn(cls, *args, **kwargs):
with cls(*args, **kwargs):
pass
+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
+50 -74
View File
@@ -3,10 +3,17 @@ import os
from collections import defaultdict
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Tuple, Union
from typing import (
TYPE_CHECKING,
Callable,
ContextManager,
Iterable,
List,
Optional,
Tuple,
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
@@ -116,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)
@@ -125,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,
@@ -232,8 +209,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()
@@ -388,16 +365,12 @@ class Repo:
self._reset()
@property
def data_index(self) -> Optional["DataIndex"]:
def data_index(self) -> "DataIndex":
from dvc_data.index import DataIndex
if not self.index_db_dir:
return None
if self._data_index is None:
index_dir = os.path.join(self.index_db_dir, "index", "data")
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
@@ -435,9 +408,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":
@@ -463,6 +439,11 @@ class Repo:
return brancher(self, *args, **kwargs)
def switch(self, rev: str) -> ContextManager[str]:
from dvc.repo.brancher import switch
return switch(self, rev)
def used_objs( # noqa: PLR0913
self,
targets=None,
@@ -576,40 +557,35 @@ 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 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
cache_dir = platformdirs.site_cache_dir("dvc", "iterative", opinion=True)
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
repo_token = hashlib.md5(os.fsencode(root_dir)).hexdigest() # noqa: S324
return os.path.join(repos_dir, repo_token)
def close(self):
self.scm.close()
+68 -16
View File
@@ -1,8 +1,15 @@
import logging
from typing import Optional
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator, Optional, Tuple
from scmrepo.git import Git
from dvc.exceptions import NotDvcRepoError
from dvc.scm import iter_revs
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
@@ -83,23 +90,68 @@ def brancher( # noqa: E302
)
try:
from dvc.fs import GitFileSystem
for sha, names in found_revs.items():
logger.trace( # type: ignore[attr-defined]
"switching fs to revision %s [%s]", sha[:7], ", ".join(names)
)
self.fs = GitFileSystem(scm=scm, rev=sha)
self.root_dir = self.fs.path.join("/", *repo_root_parts)
if cwd_parts:
cwd = self.fs.path.join("/", *cwd_parts) # type: ignore[unreachable]
self.fs.path.chdir(cwd)
# ignore revs that don't contain repo root
# (i.e. revs from before a subdir=True repo was init'ed)
if self.fs.exists(self.root_dir):
try:
_switch_fs(self, sha, repo_root_parts, cwd_parts)
yield sha if sha_only else ",".join(names)
except NotDvcRepoError:
# ignore revs that don't contain repo root
# (i.e. revs from before a subdir=True repo was init'ed)
pass
finally:
self.fs = saved_fs
self.root_dir = saved_root
def _switch_fs(
repo: "Repo",
rev: str,
repo_root_parts: Tuple[str, ...],
cwd_parts: Tuple[str, ...],
):
from dvc.fs import GitFileSystem, LocalFileSystem
if rev == "workspace":
logger.trace("switching fs to workspace") # type: ignore[attr-defined]
repo.fs = LocalFileSystem(url=repo.root_dir)
return
logger.trace("switching fs to revision %s", rev[:7]) # type: ignore[attr-defined]
assert isinstance(repo.scm, Git)
fs = GitFileSystem(scm=repo.scm, rev=rev)
root_dir = repo.fs.path.join("/", *repo_root_parts)
if not fs.exists(root_dir):
raise NotDvcRepoError(f"Commit '{rev[:7]}' does not contain a DVC repo")
repo.fs = fs
repo.root_dir = root_dir
if cwd_parts:
cwd = repo.fs.path.join("/", *cwd_parts)
repo.fs.path.chdir(cwd)
@contextmanager
def switch(repo: "Repo", rev: str) -> Iterator[str]:
"""Switch to a specific revision."""
from dvc.scm import resolve_rev
if rev != "workspace":
rev = resolve_rev(repo.scm, rev)
repo_root_parts = ()
if repo.fs.path.isin(repo.root_dir, repo.scm.root_dir):
repo_root_parts = repo.fs.path.relparts(repo.root_dir, repo.scm.root_dir)
cwd_parts = ()
if repo.fs.path.isin(repo.fs.path.getcwd(), repo.scm.root_dir):
cwd_parts = repo.fs.path.relparts(repo.fs.path.getcwd(), repo.scm.root_dir)
saved_fs = repo.fs
saved_root = repo.root_dir
try:
_switch_fs(repo, rev, repo_root_parts, cwd_parts)
yield rev
finally:
repo.fs = saved_fs
repo.root_dir = saved_root
+1 -4
View File
@@ -182,10 +182,7 @@ def _diff_head_to_index(
) -> Dict[str, List[str]]:
index = repo.index.data["repo"]
for rev in repo.brancher(revs=[head]):
if rev == "workspace":
continue
with repo.switch(head):
head_index = repo.index.data["repo"]
with ui.status("Calculating diff between head/index"):
+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]
+20 -10
View File
@@ -1,7 +1,6 @@
import logging
import os
import re
import time
from typing import TYPE_CHECKING, Dict, Iterable, 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
@@ -193,8 +204,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:
+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.")
+57 -1
View File
@@ -21,6 +21,7 @@ from typing import (
Union,
)
from funcy import get_in
from scmrepo.exceptions import SCMError
from dvc.env import DVC_EXP_AUTO_PUSH, DVC_EXP_GIT_REMOTE
@@ -255,6 +256,60 @@ class BaseExecutor(ABC):
**kwargs,
)
@classmethod
def save(
cls,
info: "ExecutorInfo",
force: bool = False,
include_untracked: Optional[List[str]] = None,
) -> ExecutorResult:
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)
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
@@ -265,7 +320,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:
@@ -590,6 +645,7 @@ class BaseExecutor(ABC):
info.name,
"dvc",
experiment_rev=dvc.experiments.scm.get_ref(EXEC_BRANCH),
metrics=get_in(dvc.metrics.show(), ["", "data"]),
)
if infofile is not None:
+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)
+67 -15
View File
@@ -1,35 +1,82 @@
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.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm import TqdmGit, iter_revs
from dvc.scm import Git, TqdmGit, iter_revs
from dvc.ui import ui
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__)
def notify_refs_to_studio(
repo: "Repo", git_remote: str, **refs: List[str]
) -> Optional[str]:
config = repo.config["feature"]
refs = compact(refs)
if not refs or env2bool("DVC_TEST"):
return None
if not (config.get("studio_token") or config["push_exp_to_studio"]):
logger.debug(
"Either feature.studio_token or feature.push_exp_to_studio config "
"needs to be set."
)
return None
import os
token = os.environ.get("STUDIO_TOKEN") or config.get("studio_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 = config.get("studio_url")
d = studio.notify_refs(repo_url, token, studio_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))
@@ -69,11 +116,15 @@ def push( # noqa: C901
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]]
refs = push_result[SyncStatus.SUCCESS]
pushed_refs = [str(r) for r in refs]
url = notify_refs_to_studio(repo, git_remote, pushed=pushed_refs)
return {"pushed": [ref.name for ref in refs], "url": url}
def _push(
repo,
repo: "Repo",
git_remote: str,
refs: Iterable["ExpRefInfo"],
force: bool,
@@ -83,7 +134,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 +156,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,
):
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)
+6
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
@@ -415,6 +417,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
+22
View File
@@ -295,6 +295,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()
+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())
+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)
+12 -7
View File
@@ -19,7 +19,7 @@ from typing import (
from scmrepo.exceptions import SCMError as InnerScmError
from dvc.exceptions import DvcException
from dvc.scm import Git, RevError, SCMError, iter_revs, resolve_rev
from dvc.scm import Git, SCMError, iter_revs, resolve_rev
from .refs import ExpRefInfo
from .serialize import SerializableError, SerializableExp
@@ -81,7 +81,10 @@ def collect_experiment_commit(
if exp_rev != "workspace" and not (force or param_deps):
cached_exp = cache.get(exp_rev)
if cached_exp:
if status == ExpStatus.Running:
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):
@@ -97,7 +100,12 @@ def collect_experiment_commit(
force=force,
**kwargs,
)
if exp_rev != "workspace" and not param_deps and not exp.contains_error:
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
@@ -114,10 +122,8 @@ def _collect_from_repo(
**kwargs,
) -> "SerializableExp":
running = running or {}
for rev in repo.brancher(revs=[exp_rev]):
with repo.switch(exp_rev) as rev:
if rev == "workspace":
if exp_rev != "workspace":
continue
timestamp: Optional[datetime] = None
else:
commit = repo.scm.resolve_commit(rev)
@@ -143,7 +149,6 @@ def _collect_from_repo(
executor=executor,
error=error,
)
raise RevError(f"nonexistent exp rev: '{exp_rev}'")
def _collect_complete_experiment(
+8 -6
View File
@@ -35,6 +35,7 @@ from .refs import (
if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.scm import NoSCM
EXEC_TMP_DIR = "exps"
@@ -108,7 +109,9 @@ def exp_refs_by_rev(scm: "Git", rev: str) -> Generator[ExpRefInfo, None, None]:
def exp_refs_by_baseline(
scm: "Git", revs: Optional[Set[str]] = None, url: Optional[str] = None
scm: "Git",
revs: Optional[Set[str]] = None,
url: Optional[str] = None,
) -> Mapping[str, List[ExpRefInfo]]:
"""Iterate over all experiment refs with the specified baseline."""
all_exp_refs = exp_refs(scm, url)
@@ -212,8 +215,6 @@ def exp_commits(
refs = ref_infos if ref_infos else exp_refs(scm)
for ref_info in refs:
shas.update(scm.branch_revs(str(ref_info), ref_info.baseline_sha))
if ref_info.baseline_sha:
shas.add(ref_info.baseline_sha)
yield from shas
@@ -233,7 +234,7 @@ def remove_exp_refs(scm: "Git", ref_infos: Iterable[ExpRefInfo]):
scm.remove_ref(str(ref_info))
def fix_exp_head(scm: "Git", ref: Optional[str]) -> Optional[str]:
def fix_exp_head(scm: Union["Git", "NoSCM"], ref: Optional[str]) -> Optional[str]:
if ref:
name, tail = Git.split_ref_pattern(ref)
if name == "HEAD" and scm.get_ref(EXEC_BASELINE):
@@ -285,7 +286,7 @@ def check_ref_format(scm: "Git", ref: ExpRefInfo):
)
def fetch_all_exps(scm: "Git", url: str, progress: Optional[Callable] = None):
def fetch_all_exps(scm: "Git", url: str, progress: Optional[Callable] = None, **kwargs):
refspecs = [
f"{ref}:{ref}"
for ref in iter_remote_refs(scm, url, base=EXPS_NAMESPACE)
@@ -295,6 +296,7 @@ def fetch_all_exps(scm: "Git", url: str, progress: Optional[Callable] = None):
url,
refspecs,
progress=progress,
**kwargs,
)
@@ -337,7 +339,7 @@ def to_studio_params(dvc_params):
if not dvc_params:
return result
for rev_data in dvc_params.values():
for file_name, file_data in rev_data["data"].items():
for file_name, file_data in rev_data.get("data", {}).items():
result[file_name] = file_data["data"]
return result
+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):
+16 -24
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))
@@ -354,42 +354,34 @@ class Index:
@cached_property
def data(self) -> "Dict[str, DataIndex]":
from dvc_data.index import DataIndex
prefix: "DataIndexKey"
loaded = False
index = self.repo.data_index
if index is None:
index = DataIndex()
prefix = ("tree", self.data_tree.hash_info.value)
if index.has_node(prefix):
loaded = True
try:
if not loaded:
_load_data_from_outs(index, prefix, self.outs)
index.commit()
if not loaded:
_load_data_from_outs(index, prefix, self.outs)
index.commit()
by_workspace = {}
by_workspace["repo"] = index.view((*prefix, "repo"))
by_workspace["local"] = index.view((*prefix, "local"))
by_workspace = {}
by_workspace["repo"] = index.view((*prefix, "repo"))
by_workspace["local"] = index.view((*prefix, "local"))
for out in self.outs:
if not out.use_cache:
continue
for out in self.outs:
if not out.use_cache:
continue
ws, key = out.index_key
if ws not in by_workspace:
by_workspace[ws] = index.view((*prefix, ws))
ws, key = out.index_key
if ws not in by_workspace:
by_workspace[ws] = index.view((*prefix, ws))
data_index = by_workspace[ws]
_load_storage_from_out(data_index.storage_map, key, out)
data_index = by_workspace[ws]
_load_storage_from_out(data_index.storage_map, key, out)
return by_workspace
finally:
index.close()
return by_workspace
@staticmethod
def _hash_targets(
+6 -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,11 @@ 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()
remove(proj.site_cache_dir)
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)
+6 -3
View File
@@ -1,5 +1,7 @@
from contextlib import suppress
from typing import TYPE_CHECKING, Optional, Sequence
from dvc.config import NoRemoteError
from dvc.exceptions import InvalidArgumentError, UploadError
from dvc.utils import glob_targets
@@ -30,9 +32,10 @@ def push( # noqa: C901, PLR0913
include_imports=False,
):
worktree_remote: Optional["Remote"] = None
_remote = self.cloud.get_remote(name=remote)
if _remote.worktree or _remote.fs.version_aware:
worktree_remote = _remote
with suppress(NoRemoteError):
_remote = self.cloud.get_remote(name=remote)
if _remote and (_remote.worktree or _remote.fs.version_aware):
worktree_remote = _remote
pushed = 0
used_run_cache = self.stage_cache.push(remote, odb=odb) if run_cache else []
+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
)
+2 -1
View File
@@ -165,7 +165,7 @@ def clone(url: str, to_path: str, **kwargs):
raise CloneError("SCM error") from exc
def resolve_rev(scm: "Git", rev: str) -> str:
def resolve_rev(scm: Union["Git", "NoSCM"], rev: str) -> str:
from scmrepo.exceptions import RevError as InternalRevError
from dvc.repo.experiments.utils import fix_exp_head
@@ -173,6 +173,7 @@ def resolve_rev(scm: "Git", rev: str) -> str:
try:
return scm.resolve_rev(fix_exp_head(scm, rev))
except InternalRevError as exc:
assert isinstance(scm, Git)
# `scm` will only resolve git branch and tag names,
# if rev is not a sha it may be an abbreviated experiment name
if not (rev == "HEAD" or rev.startswith("refs/")):
+3 -3
View File
@@ -1,6 +1,5 @@
import logging
import os
import tempfile
from contextlib import contextmanager
from typing import TYPE_CHECKING, List, Optional, Tuple
@@ -167,9 +166,10 @@ class StageCache:
COMPILED_LOCK_FILE_STAGE_SCHEMA(cache)
path = self._get_cache_path(cache_key, cache_value)
parent = self.repo.cache.local.fs.path.parent(path)
local_fs = self.repo.cache.local.fs
parent = local_fs.path.parent(path)
self.repo.cache.local.makedirs(parent)
tmp = tempfile.NamedTemporaryFile(delete=False, dir=parent).name
tmp = local_fs.path.join(parent, fs.utils.tmp_fname())
assert os.path.exists(parent)
assert os.path.isdir(parent)
dump_yaml(tmp, cache)
+87 -6
View File
@@ -1,4 +1,4 @@
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from typing import (
TYPE_CHECKING,
Any,
@@ -13,11 +13,15 @@ from typing import (
Union,
)
import colorama
from dvc.utils.objects import cached_property
if TYPE_CHECKING:
from rich.console import Console as RichConsole
from rich.console import JustifyMethod, OverflowMethod
from rich.status import Status
from rich.style import Style
from rich.text import Text as RichText
from dvc.progress import Tqdm
@@ -25,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
@@ -105,8 +124,9 @@ class Console:
if indent is None and self.isatty():
indent = 2
console = self.error_console if stderr else self.rich_console
return console.print_json(
from rich.json import JSON
json = JSON.from_data(
data=data,
indent=indent,
highlight=bool(highlight),
@@ -117,6 +137,56 @@ class Console:
default=default,
sort_keys=sort_keys,
)
if not highlight:
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(
self,
*objects: Any,
sep: str = " ",
end: str = "\n",
stderr: bool = False,
style: Optional[Union[str, "Style"]] = None,
justify: Optional["JustifyMethod"] = None,
overflow: Optional["OverflowMethod"] = None,
no_wrap: Optional[bool] = None,
emoji: Optional[bool] = None,
markup: Optional[bool] = None,
highlight: Optional[bool] = None,
width: Optional[int] = None,
height: Optional[int] = None,
crop: bool = True,
soft_wrap: Optional[bool] = None,
new_line_start: bool = False,
) -> None:
if stderr:
console = self.error_console
else:
console = self.rich_console
return console.print(
*objects,
sep=sep,
end=end,
style=style,
justify=justify,
overflow=overflow,
no_wrap=no_wrap,
emoji=emoji,
markup=markup,
highlight=highlight,
width=width,
height=height,
crop=crop,
soft_wrap=soft_wrap,
new_line_start=new_line_start,
)
def write(
self,
@@ -142,10 +212,21 @@ class Console:
with Tqdm.external_write_mode(file=file):
# if we are inside pager context, send the output to rich's buffer
if styled or self._paginate.get():
console = self.error_console if stderr else self.rich_console
if styled:
return console.print(*objects, sep=sep, end=end)
return console.out(*objects, sep=sep, end=end, highlight=False)
return self.rich_print(*objects, sep=sep, end=end, stderr=stderr)
return self.rich_print(
sep.join(str(_object) for _object in objects),
style=None,
highlight=False,
emoji=False,
markup=False,
no_wrap=True,
overflow="ignore",
crop=False,
sep=sep,
end=end,
stderr=stderr,
)
values = (self.formatter.format(obj, style) for obj in objects)
return print(*values, sep=sep, end=end, file=file)
+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
+80
View File
@@ -0,0 +1,80 @@
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(
endpoint: str,
token: str,
data: Dict[str, Any],
url: Optional[str] = STUDIO_URL,
max_retries: int = 3,
timeout: int = 5,
) -> "Response":
endpoint = urljoin(url or STUDIO_URL, endpoint)
session = requests.Session()
session.mount(endpoint, HTTPAdapter(max_retries=max_retries))
logger.trace("Sending %s to %s", data, endpoint) # type: ignore[attr-defined]
headers = {"Authorization": f"token {token}"}
r = session.post(endpoint, json=data, headers=headers, timeout=timeout)
r.raise_for_status()
return r
def notify_refs(
repo_url: str,
token: str,
*,
studio_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" ({studio_url})" if studio_url else "",
)
data = {"repo_url": repo_url, "client": "dvc", "refs": refs}
try:
r = post("/webhook/dvc", token, data, url=studio_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
+13 -8
View File
@@ -30,15 +30,14 @@ classifiers = [
]
dynamic = ["version"]
dependencies = [
"appdirs>=1.4.3",
"colorama>=0.3.9",
"configobj>=5.0.6",
"distro>=1.3",
"dpath<3,>=2.1.0",
"dvc-data>=0.42.1,<0.43",
"dvc-data>=0.46.0,<0.47",
"dvc-http",
"dvc-render>=0.1.2",
"dvc-studio-client>=0.1.1",
"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",
@@ -49,6 +48,7 @@ dependencies = [
"networkx>=2.5",
"packaging>=19",
"pathspec>=0.10.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>=0.1.17,<1",
"shortuuid>=0.5",
"shtab<2,>=1.3.4",
"tabulate>=0.8.7",
@@ -73,9 +73,8 @@ gdrive = ["dvc-gdrive==2.19.1"]
gs = ["dvc-gs==2.22.0"]
hdfs = ["dvc-hdfs==2.19"]
lint = [
"mypy==1.0.1",
"pylint==2.16.3",
"types-appdirs",
"mypy==1.1.1",
"pylint==2.17.1",
"types-colorama",
"types-psutil",
"types-requests",
@@ -133,6 +132,8 @@ profile = "black"
[tool.pytest.ini_options]
addopts = "-ra --cov-config pyproject.toml --dist worksteal"
filterwarnings = [
"error::ResourceWarning",
"error::pytest.PytestUnraisableExceptionWarning",
"error::pytest_mock.PytestMockWarning",
# remove when aiobotocore supports botocore>=1.29.13
"ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
@@ -204,6 +205,7 @@ module = [
"celery.*",
"configobj.*",
"dpath.*",
"distro",
"dvc_http",
"dvc_render.*",
"dvc_ssh",
@@ -218,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",
+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
+6 -23
View File
@@ -1,4 +1,3 @@
import gc
import json
import os
import sys
@@ -174,22 +173,20 @@ def mocked_webbrowser_open(mocker):
mocker.patch("webbrowser.open")
@pytest.fixture(autouse=True)
def isolate(tmp_path_factory, monkeypatch) -> None:
@pytest.fixture(scope="session", autouse=True)
def isolate(tmp_path_factory):
path = tmp_path_factory.mktemp("mock")
home_dir = path / "home"
home_dir.mkdir()
monkeypatch = pytest.MonkeyPatch()
if sys.platform == "win32":
home_drive, home_path = os.path.splitdrive(home_dir)
monkeypatch.setenv("USERPROFILE", str(home_dir))
monkeypatch.setenv("HOMEDRIVE", home_drive)
monkeypatch.setenv("HOMEPATH", home_path)
for env_var, sub_path in (
("APPDATA", "Roaming"),
("LOCALAPPDATA", "Local"),
):
for env_var, sub_path in (("APPDATA", "Roaming"), ("LOCALAPPDATA", "Local")):
path = home_dir / "AppData" / sub_path
path.mkdir(parents=True)
monkeypatch.setenv(env_var, os.fspath(path))
@@ -209,6 +206,8 @@ defaultBranch=master
import pygit2
pygit2.settings.search_path[pygit2.GIT_CONFIG_LEVEL_GLOBAL] = str(home_dir)
yield
monkeypatch.undo()
@pytest.fixture
@@ -244,19 +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_311(mocker):
if sys.version_info < (3, 11) and os.name != "nt":
return
from dvc.repo import Repo
close = Repo.close
def wrapped(repo):
close(repo)
gc.collect()
mocker.patch("dvc.repo.Repo.close", wrapped)
+3 -3
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)
+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)
+4 -1
View File
@@ -5,6 +5,9 @@ from tests.unit.repo.experiments.conftest import ( # noqa, pylint disable=unuse
exp_stage,
failed_checkpoint_stage,
failed_exp_stage,
session_app,
session_queue,
session_worker,
test_queue,
)
@@ -24,7 +27,7 @@ def http_auth_patch(mocker):
@pytest.fixture(params=[True, False])
def workspace(request, test_queue) -> bool: # noqa: F811
def workspace(request, session_queue) -> bool: # noqa: F811
return request.param
+9 -2
View File
@@ -1,3 +1,5 @@
import os
import pytest
from funcy import first
@@ -26,7 +28,12 @@ def test_apply(tmp_dir, scm, dvc, exp_stage):
assert (tmp_dir / "metrics.yaml").read_text().strip() == "foo: 3"
def test_apply_failed(tmp_dir, scm, dvc, failed_exp_stage, test_queue):
@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
dvc.experiments.run(failed_exp_stage.addressing, params=["foo=2"], queue=True)
@@ -35,7 +42,7 @@ def test_apply_failed(tmp_dir, scm, dvc, failed_exp_stage, test_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 -15
View File
@@ -25,9 +25,7 @@ def test_new_checkpoint(tmp_dir, scm, dvc, checkpoint_stage, mocker, workspace,
exp = first(results)
new_mock.assert_called_once()
for rev in dvc.brancher([exp]):
if rev == "workspace":
continue
with dvc.switch(exp):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(checkpoint_stage.iterations)
@@ -69,9 +67,7 @@ def test_resume_checkpoint(
)
exp = first(results)
for rev in dvc.brancher([exp]):
if rev == "workspace":
continue
with dvc.switch(exp):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(2 * checkpoint_stage.iterations)
@@ -94,9 +90,7 @@ def test_reset_checkpoint(tmp_dir, scm, dvc, checkpoint_stage, caplog, workspace
)
exp = first(results)
for rev in dvc.brancher([exp]):
if rev == "workspace":
continue
with dvc.switch(exp):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(checkpoint_stage.iterations)
@@ -132,18 +126,14 @@ def test_resume_branch(tmp_dir, scm, dvc, checkpoint_stage, workspace):
)
checkpoint_b = first(results)
for rev in dvc.brancher([checkpoint_a]):
if rev == "workspace":
continue
with dvc.switch(checkpoint_a):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(2 * checkpoint_stage.iterations)
with fs.open("metrics.yaml") as fobj:
assert fobj.read().strip() == "foo: 2"
for rev in dvc.brancher([checkpoint_b]):
if rev == "workspace":
continue
with dvc.switch(checkpoint_b):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(2 * checkpoint_stage.iterations)
-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": {}}
+51 -8
View File
@@ -5,6 +5,7 @@ import stat
from textwrap import dedent
import pytest
from configobj import ConfigObj
from funcy import first
from dvc.dvcfile import PROJECT_FILE
@@ -13,7 +14,7 @@ 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
@@ -131,7 +132,7 @@ def test_get_baseline(tmp_dir, scm, dvc, exp_stage):
assert dvc.experiments.get_baseline(f"{CELERY_STASH}@{{1}}") == init_rev
def test_update_py_params(tmp_dir, scm, dvc, test_queue, copy_script):
def test_update_py_params(tmp_dir, scm, dvc, session_queue, copy_script):
tmp_dir.gen("params.py", "INT = 1\n")
stage = dvc.run(
cmd="python copy.py params.py metrics.py",
@@ -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
@@ -597,17 +599,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 +622,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 +648,38 @@ 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"
+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")
+9 -1
View File
@@ -19,10 +19,12 @@ def test_celery_logs(
failed_exp_stage,
follow,
capsys,
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())
@@ -33,10 +35,15 @@ def test_celery_logs(
assert "failed to reproduce 'failed-copy-file'" in captured.out
@pytest.mark.xfail(
strict=False,
reason="https://github.com/iterative/dvc/issues/9143",
)
def test_queue_remove_done(
dvc,
exp_stage,
failed_exp_stage,
test_queue,
):
queue_length = 3
success_tasks = []
@@ -57,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
+4 -1
View File
@@ -76,7 +76,10 @@ 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]) == {
"pushed": [],
"url": None,
}
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()
+41 -30
View File
@@ -1,5 +1,3 @@
import logging
import pytest
from funcy import first
@@ -8,18 +6,21 @@ from dvc.repo.experiments.utils import exp_refs_by_rev
from dvc.scm import resolve_rev
@pytest.fixture
def modified_exp_stage(exp_stage, tmp_dir):
with open(tmp_dir / "copy.py", "a", encoding="utf-8") as fh:
fh.write("\n# dummy change")
def setup_stage(tmp_dir, dvc, scm):
tmp_dir.gen("params.yaml", "foo: 1")
dvc.run(name="echo-foo", outs=["bar"], cmd="echo ${foo} > bar")
scm.add(["dvc.yaml", "dvc.lock", ".gitignore", "params.yaml"])
scm.commit("init")
def test_exp_save_unchanged(tmp_dir, dvc, scm, exp_stage):
def test_exp_save_unchanged(tmp_dir, dvc, scm):
setup_stage(tmp_dir, dvc, scm)
dvc.experiments.save()
@pytest.mark.parametrize("name", (None, "test"))
def test_exp_save(tmp_dir, dvc, scm, exp_stage, name, modified_exp_stage):
def test_exp_save(tmp_dir, dvc, scm, name):
setup_stage(tmp_dir, dvc, scm)
baseline = scm.get_rev()
exp = dvc.experiments.save(name=name)
@@ -32,12 +33,12 @@ def test_exp_save(tmp_dir, dvc, scm, exp_stage, name, modified_exp_stage):
assert resolve_rev(scm, exp_name) == exp
def test_exp_save_overwrite_experiment(
tmp_dir, dvc, scm, exp_stage, modified_exp_stage
):
def test_exp_save_overwrite_experiment(tmp_dir, dvc, scm):
setup_stage(tmp_dir, dvc, scm)
name = "dummy"
dvc.experiments.save(name=name)
tmp_dir.gen("params.yaml", "foo: 2")
with pytest.raises(ExperimentExistsError):
dvc.experiments.save(name=name)
@@ -54,12 +55,14 @@ def test_exp_save_overwrite_experiment(
"invalidname.",
),
)
def test_exp_save_invalid_name(tmp_dir, dvc, scm, exp_stage, name):
def test_exp_save_invalid_name(tmp_dir, dvc, scm, name):
setup_stage(tmp_dir, dvc, scm)
with pytest.raises(InvalidArgumentError):
dvc.experiments.save(name=name, force=True)
def test_exp_save_after_commit(tmp_dir, dvc, scm, exp_stage):
def test_exp_save_after_commit(tmp_dir, dvc, scm):
setup_stage(tmp_dir, dvc, scm)
baseline = scm.get_rev()
dvc.experiments.save(name="exp-1", force=True)
@@ -72,36 +75,44 @@ def test_exp_save_after_commit(tmp_dir, dvc, scm, exp_stage):
def test_exp_save_with_staged_changes(tmp_dir, dvc, scm):
setup_stage(tmp_dir, dvc, scm)
tmp_dir.gen({"deleted": "deleted", "modified": "modified"})
scm.add_commit(["deleted", "modified"], "init")
(tmp_dir / "deleted").unlink()
tmp_dir.gen({"new_file": "new_file"})
scm.add("new_file")
(tmp_dir / "modified").write_text("foo")
scm.add(["deleted", "new_file", "modified"])
dvc.experiments.save(name="exp")
_, _, unstaged = scm.status()
assert "new_file" in unstaged
exp_rev = dvc.experiments.save(name="exp")
scm.checkout(exp_rev, force=True)
assert not (tmp_dir / "deleted").exists()
assert (tmp_dir / "new_file").exists()
assert (tmp_dir / "modified").read_text() == "foo"
def test_exp_save_include_untracked(tmp_dir, dvc, scm, exp_stage):
def test_exp_save_include_untracked(tmp_dir, dvc, scm):
setup_stage(tmp_dir, dvc, scm)
new_file = tmp_dir / "new_file"
for i in range(2):
new_file.write_text(f"exp-{i}")
dvc.experiments.save(name=f"exp-{i}", include_untracked=["new_file"])
new_file.write_text("new_file")
dvc.experiments.save(name="exp", include_untracked=["new_file"])
_, _, unstaged = scm.status()
assert "new_file" in unstaged
assert new_file.read_text() == f"exp-{i}"
dvc.experiments.apply("exp-0")
assert new_file.read_text() == "exp-0"
assert new_file.read_text() == "new_file"
def test_exp_save_include_untracked_warning(tmp_dir, dvc, scm, caplog, exp_stage):
def test_exp_save_include_untracked_warning(tmp_dir, dvc, scm, mocker):
"""Regression test for https://github.com/iterative/dvc/issues/9061"""
setup_stage(tmp_dir, dvc, scm)
new_dir = tmp_dir / "new_dir"
new_dir.mkdir()
(new_dir / "foo").write_text("foo")
(new_dir / "bar").write_text("bar")
with caplog.at_level(logging.WARNING, logger="dvc.repo.experiments.save"):
dvc.experiments.save(name="exp", include_untracked=["new_dir"])
assert not caplog.records
logger = mocker.patch("dvc.repo.experiments.executor.base.logger")
dvc.experiments.save(name="exp", include_untracked=["new_dir"])
assert not logger.warning.called
+12 -15
View File
@@ -41,17 +41,6 @@ def make_executor_info(**kwargs):
return ExecutorInfo(**kwargs)
@pytest.mark.vscode
def test_show_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.show() == {}
@pytest.mark.vscode
def test_show_branch_and_tag_name(tmp_dir, scm, dvc, exp_stage):
with tmp_dir.branch("new/branch", new=True):
@@ -167,15 +156,18 @@ def test_show_queued(tmp_dir, scm, dvc, exp_stage):
@pytest.mark.vscode
def test_show_failed_experiment(tmp_dir, scm, dvc, failed_exp_stage):
def test_show_failed_experiment(tmp_dir, scm, dvc, failed_exp_stage, test_queue):
baseline_rev = scm.get_rev()
timestamp = datetime.fromtimestamp(
scm.gitpython.repo.rev_parse(baseline_rev).committed_date
)
dvc.experiments.run(failed_exp_stage.addressing, params=["foo=2"], queue=True)
dvc.experiments.run(
failed_exp_stage.addressing, params=["foo=2"], queue=True, name="show-failed"
)
exp_rev = dvc.experiments.scm.resolve_rev(f"{CELERY_STASH}@{{0}}")
dvc.experiments.run(run_all=True)
test_queue.wait(["show-failed"])
experiments = dvc.experiments.show()[baseline_rev]
expected_baseline = {
@@ -553,15 +545,20 @@ def test_show_running_celery(tmp_dir, scm, dvc, exp_stage, mocker):
assert results["workspace"]["baseline"]["data"]["status"] == "Success"
def test_show_running_checkpoint(tmp_dir, scm, dvc, checkpoint_stage, mocker):
def test_show_running_checkpoint(
tmp_dir, scm, dvc, checkpoint_stage, mocker, test_queue
):
from dvc.repo.experiments.executor.local import TempDirExecutor
baseline_rev = scm.get_rev()
dvc.experiments.run(checkpoint_stage.addressing, params=["foo=2"], queue=True)
dvc.experiments.run(
checkpoint_stage.addressing, params=["foo=2"], queue=True, name="foo"
)
queue = dvc.experiments.celery_queue
entries = list(queue.iter_queued())
run_results = dvc.experiments.run(run_all=True)
test_queue.wait(["foo"])
checkpoint_rev = first(run_results)
exp_ref = first(exp_refs_by_rev(scm, checkpoint_rev))
+17
View File
@@ -44,6 +44,23 @@ def test_with_dict_data(tmp_dir, dvc):
assert not resolver.tracked_vars["build@model2"]
def test_with_dict_with_non_str_keys(tmp_dir, dvc):
resolver = DataResolver(dvc, tmp_dir.fs_path, {})
context = Context()
foreach_data = {2021: {"thresh": "foo"}, 2022: {"thresh": "bar"}}
data = {"foreach": foreach_data, "do": {"cmd": "echo ${key} ${item.thresh}"}}
definition = ForeachDefinition(resolver, context, "build", data)
assert definition.resolve_one("2021") == {"build@2021": {"cmd": "echo 2021 foo"}}
assert definition.resolve_one("2022") == {"build@2022": {"cmd": "echo 2022 bar"}}
# check that `foreach` item-key replacement didnot leave any leftovers.
assert not context
assert not resolver.tracked_vars["build@2021"]
assert not resolver.tracked_vars["build@2022"]
def test_with_composite_list(tmp_dir, dvc):
resolver = DataResolver(dvc, tmp_dir.fs_path, {})
+5 -2
View File
@@ -418,5 +418,8 @@ def test_show_plots_defined_with_native_os_path(tmp_dir, dvc, scm, capsys):
assert main(["plots", "show", "--json"]) == 0
out, _ = capsys.readouterr()
json_out = json.loads(out)
assert json_out[f"dvc.yaml::{top_level_plot}"]
assert json_out[stage_plot]
assert "errors" not in json_out
json_data = json_out["data"]
assert json_data[f"dvc.yaml::{top_level_plot}"]
assert json_data[stage_plot]
+2 -2
View File
@@ -245,7 +245,7 @@ def test_merging_two_levels(dvc):
def test_config_loads_without_error_for_non_dvc_repo(tmp_dir):
# regression testing for https://github.com/iterative/dvc/issues/3328
Config(validate=True)
Config.from_cwd(validate=True)
@pytest.mark.parametrize(
@@ -284,7 +284,7 @@ def test_config_gdrive_fields(tmp_dir, dvc):
"profile": "myprofile",
}
Config(validate=True)
Config.from_cwd(validate=True)
def test_config_remote(tmp_dir, dvc, capsys):
+1 -3
View File
@@ -358,11 +358,9 @@ def setup_targets_test(tmp_dir):
def test_targets_missing_path(tmp_dir, scm, dvc):
from dvc.exceptions import PathMissingError
setup_targets_test(tmp_dir)
with pytest.raises(PathMissingError):
with pytest.raises(FileNotFoundError):
dvc.diff(targets=["missing"])
+7 -7
View File
@@ -21,11 +21,11 @@ def test_external_repo(erepo_dir, mocker):
clone_spy = mocker.spy(Git, "clone")
with external_repo(url) as repo:
with repo.open_by_relpath("file") as fd:
with repo.dvcfs.open("file") as fd:
assert fd.read() == "master"
with external_repo(url, rev="branch") as repo:
with repo.open_by_relpath("file") as fd:
with repo.dvcfs.open("file") as fd:
assert fd.read() == "branch"
assert clone_spy.call_count == 1
@@ -122,7 +122,7 @@ def test_relative_remote(erepo_dir, tmp_dir):
with external_repo(url) as repo:
assert os.path.isabs(repo.config["remote"]["upstream"]["url"])
assert os.path.isdir(repo.config["remote"]["upstream"]["url"])
with repo.open_by_relpath("file") as fd:
with repo.dvcfs.open("file") as fd:
assert fd.read() == "contents"
@@ -136,7 +136,7 @@ def test_shallow_clone_branch(erepo_dir, mocker):
clone_spy = mocker.spy(Git, "clone")
with external_repo(url, rev="branch") as repo:
with repo.open_by_relpath("file") as fd:
with repo.dvcfs.open("file") as fd:
assert fd.read() == "branch"
clone_spy.assert_called_with(url, ANY, shallow_branch="branch", progress=ANY)
@@ -146,7 +146,7 @@ def test_shallow_clone_branch(erepo_dir, mocker):
mock_fetch = mocker.patch.object(Git, "fetch")
with external_repo(url) as repo:
with repo.open_by_relpath("file") as fd:
with repo.dvcfs.open("file") as fd:
assert fd.read() == "master"
mock_fetch.assert_called_with(unshallow=True)
@@ -161,7 +161,7 @@ def test_shallow_clone_tag(erepo_dir, mocker):
clone_spy = mocker.spy(Git, "clone")
with external_repo(url, rev="v1") as repo:
with repo.open_by_relpath("file") as fd:
with repo.dvcfs.open("file") as fd:
assert fd.read() == "foo"
clone_spy.assert_called_with(url, ANY, shallow_branch="v1", progress=ANY)
@@ -171,7 +171,7 @@ def test_shallow_clone_tag(erepo_dir, mocker):
mock_fetch = mocker.patch.object(Git, "fetch")
with external_repo(url, rev="master") as repo:
with repo.open_by_relpath("file") as fd:
with repo.dvcfs.open("file") as fd:
assert fd.read() == "bar"
mock_fetch.assert_called_with(unshallow=True)
+1 -7
View File
@@ -276,8 +276,7 @@ def test_gc_all_experiments(tmp_dir, scm, dvc):
(foo,) = tmp_dir.dvc_gen("foo", "foo", commit="foo")
foo_hash = foo.outs[0].hash_info.value
(bar,) = tmp_dir.dvc_gen("foo", "bar", commit="bar")
bar_hash = bar.outs[0].hash_info.value
tmp_dir.dvc_gen("foo", "bar", commit="bar")
baseline = scm.get_rev()
(baz,) = tmp_dir.dvc_gen("foo", "baz", commit="baz")
@@ -288,12 +287,7 @@ def test_gc_all_experiments(tmp_dir, scm, dvc):
dvc.gc(all_experiments=True, force=True)
# all_experiments will include the experiment commit (baz) plus baseline
# commit (bar)
assert not (tmp_dir / ".dvc" / "cache" / foo_hash[:2] / foo_hash[2:]).exists()
assert (
tmp_dir / ".dvc" / "cache" / bar_hash[:2] / bar_hash[2:]
).read_text() == "bar"
assert (
tmp_dir / ".dvc" / "cache" / baz_hash[:2] / baz_hash[2:]
).read_text() == "baz"
+3 -5
View File
@@ -217,7 +217,7 @@ def test_get_url_positive(tmp_dir, erepo_dir, caplog, local_cloud):
caplog.clear()
with caplog.at_level(logging.ERROR, logger="dvc"):
assert main(["get", os.fspath(erepo_dir), "foo", "--show-url"]) == 0
assert caplog.text == ""
assert not caplog.text
def test_get_url_not_existing(tmp_dir, erepo_dir, caplog):
@@ -231,17 +231,15 @@ def test_get_url_not_existing(tmp_dir, erepo_dir, caplog):
"--show-url",
]
)
== 1
!= 0
)
assert "failed to show URL" in caplog.text
def test_get_url_git_only_repo(tmp_dir, scm, caplog):
tmp_dir.scm_gen({"foo": "foo"}, commit="initial")
with caplog.at_level(logging.ERROR):
assert main(["get", os.fspath(tmp_dir), "foo", "--show-url"]) == 1
assert "failed to show URL" in caplog.text
assert main(["get", os.fspath(tmp_dir), "foo", "--show-url"]) != 0
def test_get_pipeline_tracked_outs(tmp_dir, dvc, scm, git_dir, run_copy, local_remote):
+4 -4
View File
@@ -8,7 +8,7 @@ from dvc.annotations import Annotation
from dvc.cachemgr import CacheManager
from dvc.config import NoRemoteError
from dvc.dvcfile import load_file
from dvc.exceptions import DownloadError, PathMissingError
from dvc.exceptions import DownloadError
from dvc.fs import system
from dvc.scm import Git
from dvc.stage.exceptions import StagePathNotFoundError
@@ -338,7 +338,7 @@ def test_push_wildcard_from_bare_git_repo(
with dvc_repo.chdir():
dvc_repo.dvc.imp(os.fspath(tmp_dir), "dirextra")
with pytest.raises(PathMissingError):
with pytest.raises(FileNotFoundError):
dvc_repo.dvc.imp(os.fspath(tmp_dir), "dir123")
@@ -405,11 +405,11 @@ def test_pull_non_workspace(tmp_dir, scm, dvc, erepo_dir):
def test_import_non_existing(erepo_dir, tmp_dir, dvc):
with pytest.raises(PathMissingError):
with pytest.raises(FileNotFoundError):
tmp_dir.dvc.imp(os.fspath(erepo_dir), "invalid_output")
# https://github.com/iterative/dvc/pull/2837#discussion_r352123053
with pytest.raises(PathMissingError):
with pytest.raises(FileNotFoundError):
tmp_dir.dvc.imp(os.fspath(erepo_dir), "/root/", "root")
+1 -1
View File
@@ -65,7 +65,7 @@ def test_init_quiet_should_not_display_welcome_screen(tmp_dir, scm, caplog):
ret = main(["init", "--quiet"])
assert ret == 0
assert caplog.text == ""
assert not caplog.text
def test_allow_init_dvc_subdir(tmp_dir, scm, monkeypatch):
+1 -1
View File
@@ -27,7 +27,7 @@ def test_unlock_lock_failed(tmp_dir, dvc, request, mocker):
# repro of a stage) in with. We should see LockError exception here.
with lock:
lock.unlock()
lock_ext.lock() # imitate an exernal process had time to lock it
lock_ext.lock() # imitate an external process had time to lock it
request.addfinalizer(lock_ext.unlock)
with pytest.raises(LockError):
lock.lock()
+3 -6
View File
@@ -5,7 +5,6 @@ from operator import itemgetter
import pytest
from dvc.exceptions import PathMissingError
from dvc.repo import Repo
from dvc.scm import CloneError
@@ -151,7 +150,7 @@ def test_ls_repo_with_path_dir_dvc_only_empty(tmp_dir, dvc, scm):
tmp_dir.scm_gen({"empty_scm_folder/": {}}, commit="add scm empty")
tmp_dir.dvc_gen({"empty_dvc_folder": {}}, commit="empty dvc folder")
with pytest.raises(PathMissingError):
with pytest.raises(FileNotFoundError):
Repo.ls(os.fspath(tmp_dir), path="not_exist_folder")
assert Repo.ls(os.fspath(tmp_dir), path="empty_scm_folder") == []
@@ -218,23 +217,21 @@ def test_ls_repo_with_missed_path(tmp_dir, dvc, scm):
tmp_dir.scm_gen(FS_STRUCTURE, commit="init")
tmp_dir.dvc_gen(DVC_STRUCTURE, commit="dvc")
with pytest.raises(PathMissingError) as exc_info:
with pytest.raises(FileNotFoundError):
Repo.ls(os.fspath(tmp_dir), path="missed_path")
assert not exc_info.value.dvc_only
def test_ls_repo_with_missed_path_dvc_only(tmp_dir, dvc, scm):
tmp_dir.scm_gen(FS_STRUCTURE, commit="init")
tmp_dir.dvc_gen(DVC_STRUCTURE, commit="dvc")
with pytest.raises(PathMissingError) as exc_info:
with pytest.raises(FileNotFoundError):
Repo.ls(
os.fspath(tmp_dir),
path="missed_path",
recursive=True,
dvc_only=True,
)
assert exc_info.value.dvc_only
def test_ls_repo_with_removed_dvc_dir(tmp_dir, dvc, scm):
+26
View File
@@ -1,6 +1,32 @@
import os
from dvc.cachemgr import CacheManager
from dvc.dvcfile import LOCK_FILE, PROJECT_FILE
from dvc.fs import system
from dvc.repo import Repo
from dvc.scm import Git
def test_open_bare(tmp_dir, scm, dvc, tmp_path_factory):
tmp_dir.dvc_gen(
{
"dir123": {"foo": "foo content"},
"dirextra": {"extrafoo": "extra foo content"},
},
commit="initial",
)
url = os.fspath(tmp_path_factory.mktemp("bare"))
Git.init(url, bare=True).close()
scm.gitpython.repo.create_remote("origin", url)
scm.gitpython.repo.remote("origin").push("master")
with Repo.open(url) as repo:
assert repo.scm.root_dir != url
with Repo.open(url, uninitialized=True) as repo:
assert repo.scm.root_dir != url
def test_destroy(tmp_dir, dvc, run_copy):

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