Compare commits

...
101 Commits
Author SHA1 Message Date
David de la Iglesia Castro 6a5fa998ad deps: bumps dvc-azure>=2.21.2
Closes #9506 via https://github.com/iterative/dvc-azure/pull/48

(cherry picked from commit a1b891ce01)
2023-05-31 14:41:41 +02:00
daavoo cbc8911863 repro: Skip pulling run cache if no-run-cache was passed.
(cherry picked from commit 5a6f834088)
2023-05-29 19:47:19 +02:00
daavoo 4a5307603f repro: Continue on RunCacheNotSupported
Closes #9470

(cherry picked from commit e98bf38d4c)
2023-05-29 19:47:19 +02:00
Peter Rowlands 6d783b8d97 exp: always log running experiment name at the start of a run
(cherry picked from commit 89109fafcf)
2023-05-23 16:14:47 +09:00
Peter Rowlands 0e0299e39d exp: support switching to live workspace exp name
(cherry picked from commit aa19e24670)
2023-05-23 16:14:47 +09:00
Peter Rowlands 1652e6af7b exp: drop deprecated get_running_exps()
- replaced with queue.collect_active()

(cherry picked from commit 629223cbc2)
2023-05-23 16:14:47 +09:00
Peter Rowlands e655844712 plots diff: use exp switch_repo context instead of brancher
(cherry picked from commit 96449f157c)
2023-05-23 16:14:47 +09:00
Peter Rowlands 98cd479876 exp: add contextmanager for switching to active tempdir repo
(cherry picked from commit 8cb2646023)
2023-05-23 16:14:47 +09:00
mergify[bot]anddaavoo 45c9b2c9f6 get_random_exp_name: Use custom random generator. (backport #9490) (#9491)
get_random_exp_name: Use custom random generator.

See https://github.com/iterative/dvclive/pull/576

Co-authored-by: Ivan Shcheklein <[email protected]>
(cherry picked from commit eb45cda5d0)

Co-authored-by: daavoo <[email protected]>
2023-05-22 16:04:14 +05:45
mergify[bot]andskshetry a9ae450a64 stage add: introduce --run option (backport #9480) (#9487)
stage add: introduce --run option (#9480)

* stage add: introduce --run option

Part of #5846.

* fix ui message

(cherry picked from commit 1989c0fe98)

Co-authored-by: skshetry <[email protected]>
2023-05-22 12:48:15 +05:45
daavoo 60e161004d exp: Fix --rev args for ls pull and remove.
Closes #9471

(cherry picked from commit 3ddd4b87fc)
2023-05-21 16:32:31 +02:00
Dave Berenbaum 45c5c83699 show hydra dumped results in debug mode (#9460)
* show hydra dumped results in debug mode

* update hydra composition trace msg
2023-05-18 13:47:58 +05:45
daavoo ecb71c8263 exp push: Handle rev arg as list. 2023-05-18 09:54:10 +02:00
David de la Iglesia Castro e6e3912da0 testing: Lazy import pytest_virtualenv (#9469) 2023-05-17 16:27:57 +03:00
Dave Berenbaum 7f6890ff25 Live metrics config (#9433)
* exp: live metrics config token and offline

* refactor live metrics config

* use get_studio_config

* revert to using env vars for studio config

* update dvc-studio-client to support live metrics config changes

* upgrade dvc-studio-client

* don't change environ when posting live metrics events
2023-05-17 09:21:01 +00:00
skshetry 99daf5d113 add: fail loudly if there's no dir cache (#9466) 2023-05-17 12:03:06 +05:45
dependabot[bot]andskshetry 83ffb8803e build(deps-dev): bump pylint from 2.17.2 to 2.17.4 (#9418)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.17.2 to 2.17.4.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.17.2...v2.17.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>
Co-authored-by: skshetry <[email protected]>
2023-05-17 09:22:30 +05:45
dependabot[bot] ace8464acb build(deps-dev): bump mypy from 1.2.0 to 1.3.0 (#9439)
Bumps [mypy](https://github.com/python/mypy) from 1.2.0 to 1.3.0.
- [Commits](https://github.com/python/mypy/compare/v1.2.0...v1.3.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-17 09:21:45 +05:45
skshetry 0dbdfd2b9b run tests and codeql on 2.x branch (#9465) 2023-05-17 09:17:36 +05:45
skshetryandpre-commit-ci[bot] 6d4251798a ci(Mergify): configuration update (#9463)
* ci(Mergify): configuration update

Signed-off-by: null <null>

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

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

---------

Signed-off-by: null <null>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-05-17 09:09:33 +05:45
skshetry 11d031935d packaging: use PyPI trusted publishers (#9459)
https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers
2023-05-17 06:26:19 +05:45
daavoo 64beb1578a repro: Add --allow-missing.
Point 2 in https://github.com/iterative/dvc/pull/9375#issuecomment-1531683194
2023-05-16 22:43:24 +02:00
dberenbaum f888544554 gc: disallow --remote without other flags 2023-05-16 17:18:57 +02:00
daavoo 1e538ed87b repro: Move pull logic to inside Stage. 2023-05-16 16:11:43 +02:00
Ruslan Kuprieiev e69deaa132 import: don't forget to set other cache config options (#9423) 2023-05-16 15:53:41 +03:00
Peter Rowlands be0a1a4a12 exp: fix temporary ref namespacing 2023-05-16 15:59:48 +09:00
skshetry 004f49744e tests: isolate dvc config and directories (#9450)
* tests: isolate dvc config and directories

by introducing envvars to change default values. This lets pytest
handle the deletion of tmpdirs, instead of filling /var/tmp with
hundreds of thousands of directories. Also isolates test run from
faulty global/system config files.

* move to dvc/env and sort
2023-05-15 18:16:02 +05:45
Alexander GuschinandOded Messer 45181504ea Add API to add artifacts (#9345)
* api call to add/write artifact

* return dvcyaml path on add

* create dir for dvc.yaml if needed

* create dir for dvc.yaml if needed - fix

* allow nested artifact names

* make dvc get download artifacts

* Apply suggestions from code review

Co-authored-by: Oded Messer <[email protected]>

* use feedback

* remove 'dvc get' from this PR, keep only adding artifacts

* allow first char of artifact name to be a number

* add note about constants

* fix parsing artifact name

* remove 'parse_name' and write tests for 'add'

* Apply suggestions from code review

Co-authored-by: Oded Messer <[email protected]>

* updates from comments

* fix for studio

* fix again

* making check_name_format to raise an exception

* Update dvc/repo/artifacts.py

---------

Co-authored-by: Oded Messer <[email protected]>
2023-05-15 07:55:43 +00:00
skshetry 915dd5062e disable nested progress bar during checkout (#9446) 2023-05-12 18:43:33 +05:45
skshetry d08c85f7d1 fix checkout callback during add/commit (#9445)
* fix checkout callback

Fix https://github.com/iterative/dvc/issues/6226.

* bump minversion of dvc-data to 0.49.1
2023-05-12 18:14:13 +05:45
skshetry 2342099bd8 add: support virtual operation (#9389) 2023-05-11 08:31:25 +05:45
dberenbaum 06d8e3c639 add random seed to benchmark stories 2023-05-10 11:08:57 +02:00
David de la Iglesia Castro 195e67c0c1 repro: pull all missing files (#9395)
* reproduce: Update `pull` to also pull `run cache`.

* reproduce: Update `pull` to also pull changed stages.
2023-05-10 00:29:44 +03:00
Peter Rowlands c75a5583b3 testing: sanitize venv names containing slashes 2023-05-09 18:52:42 +09:00
David de la Iglesia Castro 981baa8c30 studio: Send message on start event.
- Bump `dvc-studio-client>=0.9.0,<1`

Part of #8870
2023-05-08 20:08:47 +02:00
David de la Iglesia Castro aa517bc90e exp save: Support --message.
Custom commit message to use when committing the experiment.

Part of #8870
2023-05-08 20:08:47 +02:00
David de la Iglesia Castro c74dc20b97 exp run: Support --message.
Custom commit message to use when committing the experiment.

Part of #8870
2023-05-08 20:08:47 +02:00
David de la Iglesia Castro 1ba3944944 api: exp_show: Postprocess empty strings as None.
Makes it easier to manipulate when converting to pandas DataFrame.
2023-05-08 17:10:27 +02:00
David de la Iglesia Castro 8620b6e5af exp: Update --rev to support append action.
Closes #9390
2023-05-08 14:15:07 +02:00
Ruslan Kuprieiev 6b6084a848 tests: import: check that dvcfs is using main repo's cache (#9417)
Followup for https://github.com/iterative/dvc/pull/9415
2023-05-08 00:34:00 +03:00
Ruslan Kuprieiev 967dd5ea2b import: don't forget to set cache.dir (#9415)
* import: don't forget to set cache.dir

* repo: open: try setting remote for local repos
2023-05-07 05:28:58 +03:00
Ruslan Kuprieiev ad39bf14ed repo: external_repo -> _external_repo 2023-05-07 04:14:25 +03:00
Ruslan Kuprieiev 01416df5a4 get: use Repo.open instead of external_repo
This should use `Repo.open` just like `dvc import`, to benefit from
handling local repos without cloning and redundant downloaads.
2023-05-07 04:14:25 +03:00
Ruslan Kuprieiev eb592e7e33 repo: consolidate Repo.open logic 2023-05-07 04:14:25 +03:00
dberenbaum 2db07b047e data status: exclude remote status by default 2023-05-06 18:21:11 +03:00
pre-commit-ci[bot] 8ecc66ae9a [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2023-05-05 22:25:38 +02:00
paulourbano dfa5f4d9a0 utils: removing diff key from result dictionary 2023-05-05 22:25:38 +02:00
paulourbano 75952c4e36 tests: changing tests to reverted diff functionality 2023-05-05 22:25:38 +02:00
paulourbano 31b0b8de73 tests: adapting tests to new diff output
Issue #7960
2023-05-05 22:25:38 +02:00
paulourbano fae47e6122 metrics: adds support for strings in metrics
Fixes #7960
2023-05-05 22:25:38 +02:00
Ruslan Kuprieiev ac3eef439f gha: try setting PYTHONUTF8
Trying to see if that will help with encoding error on windows with python 3.11
2023-05-05 21:55:31 +03:00
Ruslan Kuprieiev b044bcce5e tests: lockfile: use different way of changing a cmd 2023-05-05 04:16:58 +03:00
Ruslan Kuprieiev a251cffdb5 deps: unpin ruamel.yaml 2023-05-05 04:16:58 +03:00
Peter Rowlands 37858be02f testing: separate session and function level scope behavior for dvc_bin 2023-05-04 17:05:43 +09:00
Ruslan Kuprieiev 9b7240e9e0 deps: temporarily pin ruamel.yaml
Temporary workaround for https://github.com/iterative/dvc/issues/9397
2023-05-04 03:22:48 +03:00
Peter Rowlands e362438f03 testing.benchmarks: support installing remote deps in benchmark venv 2023-05-03 18:19:31 +09:00
Peter Rowlands 6c79f6bbfc testing: register pytest plugin 2023-05-03 14:58:54 +09:00
Peter Rowlands 58cd1cbc09 testing.benchmarks: separate fixtures and config 2023-05-03 14:58:54 +09:00
Peter Rowlands 96e3b48afd testing: import dvc-bench benchmarks 2023-05-03 14:58:54 +09:00
dberenbaum 1f03c40dbe add: msg for overlapping parent 2023-05-02 19:03:19 +03:00
skshetry e93e8f05a3 exp push: notify studio even if cache push fails (#9380)
* exp push: notify Studio even when cache push fails

* exp push: refactor and extract exp_ref resolving logic as the function is getting complex
2023-05-02 08:46:10 +05:45
David de la Iglesia Castro 0f1326e547 api: exp_show: Cleanup flags.
Per https://github.com/iterative/dvc/pull/9356#discussion_r1180782320
2023-05-01 19:29:47 +02:00
David de la Iglesia Castro 2a04263f02 api: Add scm module with all_{X} methods for listing revs. 2023-04-28 12:33:03 +02:00
David de la Iglesia Castro 348446050e api: Add exp_show.
Closes #8775
Closes #7167
2023-04-28 12:33:03 +02:00
Ruslan Kuprieiev 7e1036b30d doctor: add system/global config location 2023-04-28 01:23:44 +03:00
Ruslan Kuprieiev a15dbc8e03 info: get_fs_type -> _get_fs_type
This is not used anywhere outside so should be private like the rest. Likely a leftover from the past.
2023-04-28 00:53:03 +03:00
Ruslan Kuprieiev 4e02197245 info: replace self -> repo
Minor cleanup of an old leftover.
2023-04-28 00:40:11 +03:00
Peter Rowlands 4ab7ef06e5 exp: pass -v loglevel into dvc-task workers 2023-04-27 20:39:26 +09:00
Peter Rowlands dae2faf36d dvc-task: bump to 0.2.1 2023-04-27 20:39:26 +09:00
David de la Iglesia Castro 58f10a4661 gc: Add ui hint for not_in_remote. 2023-04-27 10:30:39 +02:00
David de la Iglesia Castro 64e787fa02 gc: Handle remote field in --cloud and --not-in-remote. 2023-04-27 10:30:39 +02:00
David de la Iglesia Castro c7e2ec9707 gc: Add --not-in-remote arg.
Closes #2036
2023-04-27 10:30:39 +02:00
skshetry a4cc198ac7 exp push: soft wrap link so that the URL does not break (#9368)
soft wrap link so that URL does not break
2023-04-27 06:50:33 +05:45
Oded Messer d203bf9024 Fix: Prettifying unexpected error on FileExists for get/get-url/imp-url/add and introducing --force (#9365) 2023-04-27 00:41:09 +00:00
David de la Iglesia Castro baeec8ec2d Update pyproject.toml 2023-04-26 10:38:08 +02:00
dependabot[bot] 9c689a3b51 build(deps-dev): update dvc-render requirement
Updates the requirements on [dvc-render](https://github.com/iterative/dvc-render) to permit the latest version.
- [Release notes](https://github.com/iterative/dvc-render/releases)
- [Commits](https://github.com/iterative/dvc-render/compare/0.3.1...0.4.0)

---
updated-dependencies:
- dependency-name: dvc-render
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2023-04-26 10:38:08 +02:00
skshetry e1bf418e72 tests: lazy tpi imports, ignore deprecation warning from setuptools (#9362) 2023-04-24 21:34:07 +05:45
David de la Iglesia Castro b6d8c07d4d studio: Ensure studio_repo_url works with exp run --queue.
Closes #9349
2023-04-21 21:04:48 +02:00
Peter Rowlands b814531c84 exp: use mocked queue in test_apply_failed 2023-04-21 16:17:01 +09:00
Peter Rowlands 3b3cd0a0b2 exp: drop test_queue_remove_done func test in favor of mocked unit test 2023-04-21 16:17:01 +09:00
skshetry b2101a85ad reproduce: remove callback (#9354)
Callback was added for working around scm_context, since we don't use it, we don't need callback now.
2023-04-21 13:01:00 +05:45
dberenbaum ba760c56e5 exp: show active experiments first 2023-04-21 12:51:34 +09:00
dependabot[bot] 787c72ea18 build(deps-dev): bump dvc-gdrive from 2.19.1 to 2.19.2
Bumps [dvc-gdrive](https://github.com/iterative/dvc-gdrive) from 2.19.1 to 2.19.2.
- [Release notes](https://github.com/iterative/dvc-gdrive/releases)
- [Commits](https://github.com/iterative/dvc-gdrive/compare/2.19.1...2.19.2)

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-07 10:39:06 +05:45
Peter Rowlands f03d555747 deps: bump dvc-ssh to 2.22.1 2023-04-06 19:28:30 +09:00
156 changed files with 4460 additions and 2548 deletions
+9
View File
@@ -0,0 +1,9 @@
pull_request_rules:
- name: backport patches to 2.x
conditions:
- base=main
- label=backport
actions:
backport:
branches:
- "2.x"
+1 -1
View File
@@ -2,7 +2,7 @@ name: "CodeQL"
on:
push:
branches: ["main"]
branches: ["main", "2.x"]
pull_request:
branches: ["main"]
schedule:
+2 -3
View File
@@ -73,6 +73,8 @@ jobs:
pip:
runs-on: ubuntu-20.04
permissions:
id-token: write
steps:
- uses: actions/checkout@v3
with:
@@ -100,13 +102,10 @@ jobs:
- name: Publish packages to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
- name: Publish to Test PyPI
if: ${{ github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.TEST_PYPI_TOKEN }}
repository_url: https://test.pypi.org/legacy/
skip_existing: true
+3 -1
View File
@@ -2,7 +2,7 @@ name: Tests
on:
push:
branches: [main]
branches: [main, 2.x]
pull_request:
schedule:
- cron: '5 1 * * *' # every day at 01:05
@@ -42,6 +42,8 @@ jobs:
- uses: pre-commit/[email protected]
- name: run tests
timeout-minutes: 40
env:
PYTHONUTF8: 1
run: >
pytest -n=logical --timeout=300 --durations=100
--cov --cov-report=xml --cov-report=term
+1 -1
View File
@@ -43,7 +43,7 @@ repos:
hooks:
- id: isort
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.260'
rev: 'v0.0.261'
hooks:
- id: ruff
- repo: https://github.com/codespell-project/codespell
+14 -2
View File
@@ -28,9 +28,21 @@ class Annotation:
@dataclass
class Artifact(Annotation):
class Artifact:
PARAM_PATH: ClassVar[str] = "path"
path: Optional[str] = None
PARAM_DESC: ClassVar[str] = "desc"
PARAM_TYPE: ClassVar[str] = "type"
PARAM_LABELS: ClassVar[str] = "labels"
PARAM_META: ClassVar[str] = "meta"
path: str
desc: Optional[str] = None
type: Optional[str] = None # noqa: A003
labels: List[str] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, str]:
return compact(asdict(self))
ANNOTATION_FIELDS = [field.name for field in fields(Annotation)]
+6 -1
View File
@@ -2,11 +2,16 @@ 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 exp_save, make_checkpoint
from .experiments import exp_save, exp_show, make_checkpoint
from .scm import all_branches, all_commits, all_tags
from .show import metrics_show, params_show
__all__ = [
"all_branches",
"all_commits",
"all_tags",
"exp_save",
"exp_show",
"get_url",
"make_checkpoint",
"open",
+78 -1
View File
@@ -1,10 +1,13 @@
import builtins
import os
from time import sleep
from typing import List, Optional
from typing import Dict, List, Optional, Union
from rich.text import Text
from dvc.env import DVC_CHECKPOINT, DVC_ROOT
from dvc.repo import Repo
from dvc.repo.experiments.show import tabulate
from dvc.stage.monitor import CheckpointTask
@@ -69,3 +72,77 @@ def exp_save(
return repo.experiments.save(
name=name, force=force, include_untracked=include_untracked
)
def _postprocess(exp_rows):
for exp_row in exp_rows:
for k, v in exp_row.items():
if isinstance(v, Text):
v_str = str(v)
try:
exp_row[k] = float(v_str)
except ValueError:
exp_row[k] = v_str
if not exp_row[k]:
exp_row[k] = None
return exp_rows
def exp_show(
repo: Optional[str] = None,
revs: Optional[Union[str, List[str]]] = None,
num: int = 1,
param_deps: bool = False,
force: bool = False,
) -> List[Dict]:
"""Get DVC experiments tracked in `repo`.
Without arguments, this function will retrieve all experiments derived from
the Git `HEAD`.
See the options below to customize the experiments retrieved.
Args:
repo (str, optional): location of the DVC repository.
Defaults to the current project (found by walking up from the
current working directory tree).
It can be a URL or a file system path.
Both HTTP and SSH protocols are supported for online Git repos
(e.g. [user@]server:project.git).
revs (Union[str, List[str]], optional): Git revision(s) (e.g. branch,
tag, SHA commit) to use as a reference point to start listing
experiments.
Defaults to `None`, which will use `HEAD` as starting point.
num (int, optional): show experiments from the last `num` commits
(first parents) starting from the `revs` baseline.
Give a negative value to include all first-parent commits (similar
to `git log -n`).
Defaults to 1.
param_deps (bool, optional): include only parameters that are stage
dependencies.
Defaults to `False`.
force (bool, optional): force re-collection of experiments instead of
loading from internal experiments cache.
DVC caches `exp_show` data for completed experiments to improve
performance of subsequent calls.
When `force` is specified, DVC will reload all experiment data and
ignore any previously cached results.
Defaults to `False`.
Returns:
List[Dict]: Each item in the list will contain a dictionary with
the info for an individual experiment.
See Examples below.
"""
with Repo.open(repo) as _repo:
experiments = _repo.experiments.show(
revs=revs,
num=num,
param_deps=param_deps,
force=force,
)
td, _ = tabulate(experiments, fill_value=None)
return _postprocess(td.as_dict())
+54
View File
@@ -0,0 +1,54 @@
from typing import List, Optional
from dvc.repo import Repo
def all_branches(repo: Optional[str] = None) -> List[str]:
"""Get all Git branches in a DVC repository.
Args:
repo (str, optional): location of the DVC repository.
Defaults to the current project (found by walking up from the
current working directory tree).
It can be a URL or a file system path.
Both HTTP and SSH protocols are supported for online Git repos
(e.g. [user@]server:project.git).
Returns:
List[str]: Names of the Git branches.
"""
with Repo.open(repo) as _repo:
return _repo.scm.list_branches()
def all_commits(repo: Optional[str] = None) -> List[str]:
"""Get all Git commits in a DVC repository.
Args:
repo (str, optional): location of the DVC repository.
Defaults to the current project (found by walking up from the
current working directory tree).
It can be a URL or a file system path.
Both HTTP and SSH protocols are supported for online Git repos
(e.g. [user@]server:project.git).
Returns:
List[str]: SHAs of the Git commits.
"""
with Repo.open(repo) as _repo:
return _repo.scm.list_all_commits()
def all_tags(repo: Optional[str] = None) -> List[str]:
"""Get all Git tags in a DVC repository.
Args:
repo (str, optional): location of the DVC repository.
Defaults to the current project (found by walking up from the
current working directory tree).
It can be a URL or a file system path.
Both HTTP and SSH protocols are supported for online Git repos
(e.g. [user@]server:project.git).
Returns:
List[str]: Names of the Git tags.
"""
with Repo.open(repo) as _repo:
return _repo.scm.list_tags()
+1 -1
View File
@@ -245,7 +245,7 @@ def main(argv=None): # noqa: C901, PLR0912, PLR0915
finally:
logger.setLevel(outer_log_level)
from dvc.external_repo import clean_repos
from dvc.repo.open_repo import clean_repos
# Remove cached repos in the end of the call, these are anonymous
# so won't be reused by any other subsequent run anyway.
+11 -1
View File
@@ -62,8 +62,11 @@ class CmdAdd(CmdBase):
remote=self.args.remote,
to_remote=self.args.to_remote,
jobs=self.args.jobs,
force=self.args.force,
)
except FileNotFoundError:
logger.exception("")
return 1
except DvcException:
logger.exception("")
return 1
@@ -140,6 +143,13 @@ def add_parser(subparsers, parent_parser):
),
metavar="<number>",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Override local file or folder if exists.",
)
_add_annotating_args(parser)
parser.add_argument(
+9 -2
View File
@@ -117,6 +117,7 @@ class CmdDataStatus(CmdBase):
status = self.repo.data_status(
granular=self.args.granular,
untracked_files=self.args.untracked_files,
not_in_remote=self.args.not_in_remote,
remote_refresh=self.args.remote_refresh,
)
@@ -244,10 +245,16 @@ def add_parser(subparsers, parent_parser):
help="Show untracked files.",
)
data_status_parser.add_argument(
"--remote-refresh",
"--not-in-remote",
action="store_true",
default=False,
help="Refresh remote index.",
help="Show files not in remote.",
)
data_status_parser.add_argument(
"--no-remote-refresh",
dest="remote_refresh",
action="store_false",
help="Use cached remote index (don't check remote).",
)
data_status_parser.set_defaults(func=CmdDataStatus)
+1
View File
@@ -82,6 +82,7 @@ def add_rev_selection_flags(
experiments_subcmd_parser.add_argument(
"--rev",
type=str,
action="append",
default=None,
help=msg,
metavar="<commit>",
+8
View File
@@ -20,6 +20,7 @@ class CmdExecutorRun(CmdBaseNoRepo):
log_level=logger.getEffectiveLevel(),
infofile=self.args.infofile,
copy_paths=self.args.copy_paths,
message=self.args.message,
)
return 0
@@ -47,4 +48,11 @@ def add_parser(experiments_subparsers, parent_parser):
" Only used if `--temp` or `--queue` is specified."
),
)
exec_run_parser.add_argument(
"-M",
"--message",
type=str,
default=None,
help="Custom commit message to use when committing the experiment.",
)
exec_run_parser.set_defaults(func=CmdExecutorRun)
+3 -1
View File
@@ -52,7 +52,9 @@ class CmdExperimentsPush(CmdBase):
ui.write(humanize.get_summary(stats.items()))
if project_url := result.get("url"):
ui.write("[yellow]View your experiments at", project_url, styled=True)
ui.rich_print(
"View your experiments at", project_url, style="yellow", soft_wrap=True
)
def run(self):
from dvc.repo.experiments.push import UploadError
+8
View File
@@ -37,6 +37,7 @@ class CmdExperimentsRun(CmdRepro):
tmp_dir=self.args.tmp_dir,
machine=self.args.machine,
copy_paths=self.args.copy_paths,
message=self.args.message,
**self._common_kwargs,
)
@@ -147,3 +148,10 @@ def _add_run_common(parser):
" Only used if `--temp` or `--queue` is specified."
),
)
parser.add_argument(
"-M",
"--message",
type=str,
default=None,
help="Custom commit message to use when committing the experiment.",
)
+8
View File
@@ -16,6 +16,7 @@ class CmdExperimentsSave(CmdBase):
name=self.args.name,
force=self.args.force,
include_untracked=self.args.include_untracked,
message=self.args.message,
)
except DvcException:
logger.exception("failed to save experiment")
@@ -75,4 +76,11 @@ def add_parser(experiments_subparsers, parent_parser):
help="List of untracked paths to include in the experiment.",
metavar="<path>",
)
save_parser.add_argument(
"-M",
"--message",
type=str,
default=None,
help="Custom commit message to use when committing the experiment.",
)
save_parser.set_defaults(func=CmdExperimentsSave)
+18 -301
View File
@@ -2,9 +2,8 @@ import argparse
import logging
import os
import re
from collections import Counter, OrderedDict, defaultdict
from datetime import date, datetime
from typing import TYPE_CHECKING, Any, Dict, Set
from typing import TYPE_CHECKING, Dict, Iterable
from funcy import lmap
@@ -12,9 +11,8 @@ from dvc.cli import completion
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.commands.metrics import DEFAULT_PRECISION
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.exceptions import DvcException
from dvc.ui import ui
from dvc.utils.flatten import flatten
from dvc.utils.serialize import encode_exception
if TYPE_CHECKING:
@@ -28,30 +26,6 @@ FILL_VALUE_ERRORED = "!"
logger = logging.getLogger(__name__)
def _update_names(names, items):
for name, item in items:
item = item.get("data", {})
if isinstance(item, dict):
item = flatten(item)
names[name].update({key: None for key in item})
def _collect_names(all_experiments, **kwargs):
metric_names: Dict[str, Dict[str, Any]] = defaultdict(dict)
param_names: Dict[str, Dict[str, Any]] = defaultdict(dict)
deps_names: Set[str] = set()
for _, experiments in all_experiments.items():
for exp_data in experiments.values():
exp = exp_data.get("data", {})
_update_names(metric_names, exp.get("metrics", {}).items())
_update_names(param_names, exp.get("params", {}).items())
for dep_name in exp.get("deps", {}):
deps_names.add(dep_name)
return metric_names, param_names, sorted(deps_names)
experiment_types = {
"checkpoint_tip": "│ ╓",
"checkpoint_commit": "│ ╟",
@@ -62,229 +36,6 @@ experiment_types = {
}
def _collect_rows( # noqa: C901, PLR0912, PLR0913, PLR0915
base_rev,
experiments,
all_headers,
metric_headers,
param_headers,
metric_names,
param_names,
deps_names,
precision=DEFAULT_PRECISION,
sort_by=None,
sort_order=None,
fill_value=FILL_VALUE,
iso=False,
):
from dvc.scm import Git
if sort_by:
sort_path, sort_name, sort_type = _sort_column(
sort_by, metric_names, param_names
)
reverse = sort_order == "desc"
experiments = _sort_exp(experiments, sort_path, sort_name, sort_type, reverse)
new_checkpoint = True
for i, (rev, results) in enumerate(experiments.items()):
fill_value = FILL_VALUE_ERRORED if results.get("error") else fill_value
row_dict = {k: fill_value for k in all_headers}
exp = results.get("data", {})
state = exp.get("status")
if state == "Success":
state = fill_value
is_baseline = rev == "baseline"
if is_baseline:
name_rev = base_rev[:7] if Git.is_sha(base_rev) else base_rev
else:
name_rev = rev[:7]
tip = exp.get("checkpoint_tip")
parent_rev = exp.get("checkpoint_parent", "")
parent_exp = experiments.get(parent_rev, {}).get("data", {})
parent_tip = parent_exp.get("checkpoint_tip")
parent = ""
if is_baseline:
typ = "baseline"
elif tip:
if tip == parent_tip:
typ = "checkpoint_tip" if new_checkpoint else "checkpoint_commit"
elif parent_rev == base_rev:
typ = "checkpoint_base"
else:
typ = "checkpoint_commit"
parent = parent_rev[:7]
elif i < len(experiments) - 1:
typ = "branch_commit"
else:
typ = "branch_base"
if not is_baseline:
new_checkpoint = not (tip and tip == parent_tip)
row_dict["Experiment"] = exp.get("name", "")
row_dict["rev"] = name_rev
row_dict["typ"] = typ
row_dict["Created"] = format_time(exp.get("timestamp"), fill_value, iso)
row_dict["parent"] = parent
row_dict["State"] = state
row_dict["Executor"] = exp.get("executor", fill_value)
_extend_row(
row_dict,
metric_names,
metric_headers,
exp.get("metrics", {}).items(),
precision,
fill_value=fill_value,
)
_extend_row(
row_dict,
param_names,
param_headers,
exp.get("params", {}).items(),
precision,
fill_value=fill_value,
)
for dep in deps_names:
hash_info = exp.get("deps", {}).get(dep, {}).get("hash")
if hash_info is not None:
hash_info = hash_info[:7]
row_dict[dep] = hash_info
yield list(row_dict.values())
def _sort_column(sort_by, metric_names, param_names):
path, _, sort_name = sort_by.rpartition(":")
matches = set()
if path:
if path in metric_names and sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
if path in param_names and sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
else:
for path in metric_names:
if sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
for path in param_names:
if sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
if len(matches) == 1:
return matches.pop()
if len(matches) > 1:
raise InvalidArgumentError(
"Ambiguous sort column '{}' matched '{}'".format(
sort_by,
", ".join([f"{path}:{name}" for path, name, _ in matches]),
)
)
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
def _sort(item):
rev, exp = item
exp_data = exp.get("data", {})
tip = exp_data.get("checkpoint_tip")
if tip and tip != rev:
# Sort checkpoint experiments by tip commit
return _sort((tip, experiments[tip]))
data = exp_data.get(typ, {}).get(sort_path, {}).get("data", {})
val = flatten(data).get(sort_name)
return val is None, val
ret = OrderedDict()
if "baseline" in experiments:
ret["baseline"] = experiments.pop("baseline")
ret.update(sorted(experiments.items(), key=_sort, reverse=reverse))
return ret
def format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
if datetime_obj is None:
return fill_value
if iso:
return datetime_obj.isoformat()
if datetime_obj.date() == date.today():
fmt = "%I:%M %p"
else:
fmt = "%b %d, %Y"
return datetime_obj.strftime(fmt)
def _extend_row(row, names, headers, items, precision, fill_value=FILL_VALUE):
from dvc.compare import _format_field, with_value
for fname, data in items:
item = data.get("data", {})
item = flatten(item) if isinstance(item, dict) else {fname: item}
for name in names[fname]:
value = with_value(
item.get(name),
FILL_VALUE_ERRORED if data.get("error", None) else fill_value,
)
# wrap field data in ui.rich_text, otherwise rich may
# interpret unescaped braces from list/dict types as rich
# markup tags
value = ui.rich_text(str(_format_field(value, precision)))
if name in headers:
row[name] = value
else:
row[f"{fname}:{name}"] = value
def experiments_table( # noqa: PLR0913
all_experiments,
headers,
metric_headers,
metric_names,
param_headers,
param_names,
deps_names,
sort_by=None,
sort_order=None,
precision=DEFAULT_PRECISION,
fill_value=FILL_VALUE,
iso=False,
) -> "TabularData":
from funcy import lconcat
from dvc.compare import TabularData
all_headers = lconcat(headers, metric_headers, param_headers, deps_names)
td = TabularData(all_headers, fill_value=fill_value)
for base_rev, experiments in all_experiments.items():
rows = _collect_rows(
base_rev,
experiments,
all_headers,
metric_headers,
param_headers,
metric_names,
param_names,
deps_names,
sort_by=sort_by,
sort_order=sort_order,
precision=precision,
fill_value=fill_value,
iso=iso,
)
td.extend(rows)
return td
def prepare_exp_id(kwargs) -> "RichText":
exp_name = kwargs["Experiment"]
rev = kwargs["rev"]
@@ -309,7 +60,8 @@ def baseline_styler(typ):
def show_experiments( # noqa: C901, PLR0912
all_experiments,
td: "TabularData",
headers: Dict[str, Iterable[str]],
keep=None,
drop=None,
pager=True,
@@ -318,40 +70,6 @@ def show_experiments( # noqa: C901, PLR0912
pcp=False,
**kwargs,
):
from funcy.seqs import flatten as flatten_list
metric_names, param_names, deps_names = _collect_names(all_experiments)
headers_list = [
"Experiment",
"rev",
"typ",
"Created",
"parent",
"State",
"Executor",
]
names = {**metric_names, **param_names}
counter = Counter(flatten_list([list(a.keys()) for a in names.values()]))
counter.update(headers_list)
metric_headers = _normalize_headers(metric_names, counter)
param_headers = _normalize_headers(param_names, counter)
td = experiments_table(
all_experiments,
headers_list,
metric_headers,
metric_names,
param_headers,
param_names,
deps_names,
kwargs.get("sort_by"),
kwargs.get("sort_order"),
kwargs.get("precision"),
kwargs.get("fill_value"),
kwargs.get("iso"),
)
if keep:
for col in td.keys(): # noqa: SIM118
if re.match(keep, col):
@@ -368,11 +86,6 @@ def show_experiments( # noqa: C901, PLR0912
td.column("Experiment")[:] = map(prepare_exp_id, td.as_dict(merge_headers))
td.drop(*merge_headers[1:])
headers = {
"metrics": metric_headers,
"params": param_headers,
"deps": deps_names,
}
styles = {
"Experiment": {"no_wrap": True, "header_style": "black on grey93"},
"Created": {"header_style": "black on grey93"},
@@ -453,19 +166,17 @@ def _normalize_headers(names, count):
def _format_json(item):
from dvc.repo.experiments.show import _CachedError
if isinstance(item, (date, datetime)):
return item.isoformat()
if isinstance(item, _CachedError):
return {"type": getattr(item, "typ", "_CachedError"), "msg": str(item)}
return encode_exception(item)
class CmdExperimentsShow(CmdBase):
def run(self):
from dvc.repo.experiments.show import tabulate
try:
all_experiments = self.repo.experiments.show(
exps = self.repo.experiments.show(
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
@@ -483,23 +194,29 @@ class CmdExperimentsShow(CmdBase):
return 1
if self.args.json:
ui.write_json(all_experiments, default=_format_json)
ui.write_json([exp.dumpd() for exp in exps], default=_format_json)
else:
precision = (
self.args.precision or None if self.args.csv else DEFAULT_PRECISION
)
fill_value = "" if self.args.csv else FILL_VALUE
iso = self.args.csv
td, headers = tabulate(
exps,
precision=precision,
fill_value=fill_value,
iso=iso,
sort_by=self.args.sort_by,
sort_order=self.args.sort_order,
)
show_experiments(
all_experiments,
td,
headers,
keep=self.args.keep,
drop=self.args.drop,
sort_by=self.args.sort_by,
sort_order=self.args.sort_order,
precision=precision,
fill_value=fill_value,
iso=iso,
pager=not self.args.no_pager,
csv=self.args.csv,
markdown=self.args.markdown,
+15 -2
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class CmdGC(CmdBase):
def run(self): # noqa: C901
def run(self): # noqa: C901, PLR0912
from dvc.repo.gc import _validate_args
_validate_args(
@@ -22,6 +22,8 @@ class CmdGC(CmdBase):
workspace=self.args.workspace,
rev=self.args.rev,
num=self.args.num,
cloud=self.args.cloud,
not_in_remote=self.args.not_in_remote,
)
if self.args.rev:
@@ -46,6 +48,10 @@ class CmdGC(CmdBase):
if self.args.all_experiments:
msg += " and all experiments"
if self.args.not_in_remote:
msg += " that are present in the DVC remote"
if self.args.repos:
msg += " of the current and the following repos:"
@@ -74,6 +80,7 @@ class CmdGC(CmdBase):
workspace=self.args.workspace,
rev=self.args.rev,
num=self.args.num,
not_in_remote=self.args.not_in_remote,
)
return 0
@@ -157,12 +164,18 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Keep data files for all experiments.",
)
gc_parser.add_argument(
"--not-in-remote",
action="store_true",
default=False,
help="Keep data files that are not present in the remote.",
)
gc_parser.add_argument(
"-c",
"--cloud",
action="store_true",
default=False,
help="Collect garbage in remote repository.",
help="Collect garbage in remote storage in addition to local cache.",
)
gc_parser.add_argument(
"-r",
+8
View File
@@ -36,6 +36,7 @@ class CmdGet(CmdBaseNoRepo):
out=self.args.out,
rev=self.args.rev,
jobs=self.args.jobs,
force=self.args.force,
)
return 0
except CloneError:
@@ -94,4 +95,11 @@ def add_parser(subparsers, parent_parser):
),
metavar="<number>",
)
get_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Override local file or folder if exists.",
)
get_parser.set_defaults(func=CmdGet)
+13 -1
View File
@@ -14,7 +14,12 @@ class CmdGetUrl(CmdBaseNoRepo):
from dvc.repo import Repo
try:
Repo.get_url(self.args.url, out=self.args.out, jobs=self.args.jobs)
Repo.get_url(
self.args.url,
out=self.args.out,
jobs=self.args.jobs,
force=self.args.force,
)
return 0
except DvcException:
logger.exception("failed to get '%s'", self.args.url)
@@ -46,4 +51,11 @@ def add_parser(subparsers, parent_parser):
),
metavar="<number>",
)
get_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Override local file or folder if exists.",
)
get_parser.set_defaults(func=CmdGetUrl)
+8
View File
@@ -25,6 +25,7 @@ class CmdImportUrl(CmdBase):
labels=self.args.labels,
meta=self.args.meta,
jobs=self.args.jobs,
force=self.args.force,
version_aware=self.args.version_aware,
)
except DvcException:
@@ -112,6 +113,13 @@ def add_parser(subparsers, parent_parser):
),
metavar="<number>",
)
import_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Override local file or folder if exists.",
)
import_parser.add_argument(
"--version-aware",
action="store_true",
+3 -5
View File
@@ -1,10 +1,8 @@
import argparse
import logging
from typing import Dict, List, Optional
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.commands.experiments.show import format_time
from dvc.compare import TabularData
from dvc.ui import ui
@@ -15,9 +13,9 @@ class CmdQueueStatus(CmdBase):
"""Show queue task and worker status."""
def run(self) -> int:
result: List[
Dict[str, Optional[str]]
] = self.repo.experiments.celery_queue.status()
from dvc.repo.experiments.show import format_time
result = self.repo.experiments.celery_queue.status()
if result:
all_headers = ["Task", "Name", "Created", "Status"]
td = TabularData(all_headers)
+7
View File
@@ -38,6 +38,7 @@ class CmdRepro(CmdBase):
"recursive": self.args.recursive,
"force_downstream": self.args.force_downstream,
"pull": self.args.pull,
"allow_missing": self.args.allow_missing,
}
@property
@@ -135,6 +136,12 @@ and then the stage name name.
"from the run-cache."
),
)
repro_parser.add_argument(
"--allow-missing",
action="store_true",
default=False,
help=("Skip stages with missing data but no other changes."),
)
repro_parser.add_argument(
"--dry",
action="store_true",
+30 -1
View File
@@ -1,5 +1,6 @@
import argparse
import logging
from contextlib import contextmanager
from itertools import chain, filterfalse
from typing import TYPE_CHECKING, Dict, Iterable, List
@@ -119,8 +120,22 @@ def parse_cmd(commands: List[str]) -> str:
return " ".join(map(quote_argument, commands))
@contextmanager
def _disable_logging(highest_level=logging.CRITICAL):
previous_level = logging.root.manager.disable
logging.disable(highest_level)
try:
yield
finally:
logging.disable(previous_level)
class CmdStageAdd(CmdBase):
def run(self):
from dvc.repo import lock_repo
kwargs = vars(self.args)
kwargs.update(
{
@@ -128,7 +143,15 @@ class CmdStageAdd(CmdBase):
"params": parse_params(self.args.params),
}
)
self.repo.stage.add(**kwargs)
with self.repo.scm_context, lock_repo(self.repo):
with _disable_logging(logging.INFO):
stage = self.repo.stage.add(**kwargs)
logger.info("Added stage %r in %r", stage.addressing, stage.relpath)
if self.args.run:
stage.run()
stage.dump(update_pipeline=False)
return 0
@@ -257,6 +280,12 @@ def _add_common_args(parser):
"This doesn't affect any DVC operations."
),
)
parser.add_argument(
"--run",
action="store_true",
default=False,
help="Execute the stage after generating it.",
)
parser.add_argument(
"command",
nargs=argparse.REMAINDER,
+1 -1
View File
@@ -35,7 +35,7 @@ def with_value(value, default):
class TabularData(MutableSequence[Sequence["CellT"]]):
def __init__(self, columns: Sequence[str], fill_value: str = ""):
def __init__(self, columns: Sequence[str], fill_value: Optional[str] = ""):
self._columns: Dict[str, Column] = {name: Column() for name in columns}
self._keys: List[str] = list(columns)
self._fill_value = fill_value
+3 -6
View File
@@ -79,9 +79,6 @@ class Config(dict):
ConfigError: thrown if config has an invalid format.
"""
APPNAME = "dvc"
APPAUTHOR = "iterative"
SYSTEM_LEVELS = ("system", "global")
REPO_LEVELS = ("repo", "local")
# In the order they shadow each other
@@ -121,14 +118,14 @@ class Config(dict):
@classmethod
def get_dir(cls, level):
from platformdirs import site_config_dir, user_config_dir
from dvc.dirs import global_config_dir, system_config_dir
assert level in ("global", "system")
if level == "global":
return user_config_dir(cls.APPNAME, cls.APPAUTHOR)
return global_config_dir()
if level == "system":
return site_config_dir(cls.APPNAME, cls.APPAUTHOR)
return system_config_dir()
@cached_property
def files(self) -> Dict[str, str]:
+7 -3
View File
@@ -215,6 +215,7 @@ SCHEMA = {
"timeout": Coerce(int),
"gss_auth": Bool,
"allow_agent": Bool,
"max_sessions": Coerce(int),
Optional("verify", default=False): Bool,
**REMOTE_COMMON,
},
@@ -301,9 +302,6 @@ SCHEMA = {
"feature": FeatureSchema(
{
Optional("machine", default=False): Bool,
Optional("push_exp_to_studio", default=False): Bool,
"studio_token": str,
"studio_url": str,
},
),
"plots": {
@@ -329,4 +327,10 @@ SCHEMA = {
"config_dir": str,
"config_name": str,
},
"studio": {
"token": str,
"url": str,
"repo_url": str,
Optional("offline", default=False): Bool,
},
}
+4
View File
@@ -79,10 +79,14 @@ class RepoDependency(Dependency):
) -> "DVCFileSystem":
from dvc.fs import DVCFileSystem
config = {"cache": self.repo.config["cache"]}
config["cache"]["dir"] = self.repo.cache.local.path
return DVCFileSystem(
url=self.def_repo[self.PARAM_URL],
rev=rev or self._get_rev(locked=locked),
subrepos=True,
config=config,
)
def _get_rev(self, locked: bool = True):
+26
View File
@@ -0,0 +1,26 @@
import os
import platformdirs
from . import env
APPNAME = "dvc"
APPAUTHOR = "iterative"
def system_config_dir():
return os.getenv(env.DVC_SYSTEM_CONFIG_DIR) or platformdirs.site_config_dir(
APPNAME, APPAUTHOR
)
def global_config_dir():
return os.getenv(env.DVC_SYSTEM_CONFIG_DIR) or platformdirs.user_config_dir(
APPNAME, APPAUTHOR
)
def site_cache_dir():
return os.getenv(env.DVC_SITE_CACHE_DIR) or platformdirs.site_cache_dir(
APPNAME, APPAUTHOR, opinion=True
)
+14 -7
View File
@@ -1,12 +1,19 @@
DVCLIVE_RESUME = "DVCLIVE_RESUME"
DVC_CHECKPOINT = "DVC_CHECKPOINT"
DVC_DAEMON = "DVC_DAEMON"
DVC_EXP_AUTO_PUSH = "DVC_EXP_AUTO_PUSH"
DVC_EXP_BASELINE_REV = "DVC_EXP_BASELINE_REV"
DVC_EXP_GIT_REMOTE = "DVC_EXP_GIT_REMOTE"
DVC_EXP_NAME = "DVC_EXP_NAME"
DVC_GLOBAL_CONFIG_DIR = "DVC_GLOBAL_CONFIG_DIR"
DVC_IGNORE_ISATTY = "DVC_IGNORE_ISATTY"
DVC_NO_ANALYTICS = "DVC_NO_ANALYTICS"
DVC_PAGER = "DVC_PAGER"
DVC_ROOT = "DVC_ROOT"
DVCLIVE_RESUME = "DVCLIVE_RESUME"
DVC_IGNORE_ISATTY = "DVC_IGNORE_ISATTY"
DVC_EXP_BASELINE_REV = "DVC_EXP_BASELINE_REV"
DVC_EXP_NAME = "DVC_EXP_NAME"
DVC_EXP_GIT_REMOTE = "DVC_EXP_GIT_REMOTE"
DVC_EXP_AUTO_PUSH = "DVC_EXP_AUTO_PUSH"
DVC_NO_ANALYTICS = "DVC_NO_ANALYTICS"
DVC_SHOW_TRACEBACK = "DVC_SHOW_TRACEBACK"
DVC_SITE_CACHE_DIR = "DVC_SITE_CACHE_DIR"
DVC_STUDIO_OFFLINE = "DVC_STUDIO_OFFLINE"
DVC_STUDIO_REPO_URL = "DVC_STUDIO_REPO_URL"
DVC_STUDIO_TOKEN = "DVC_STUDIO_TOKEN" # noqa: S105 # nosec B105
DVC_STUDIO_URL = "DVC_STUDIO_URL"
DVC_SYSTEM_CONFIG_DIR = "DVC_SYSTEM_CONFIG_DIR"
+13
View File
@@ -1,4 +1,5 @@
"""Exceptions raised by the dvc."""
import errno
from typing import Dict, List
from dvc.utils import format_link
@@ -206,6 +207,18 @@ class ETagMismatchError(DvcException):
)
class FileExistsLocallyError(FileExistsError, DvcException):
def __init__(self, path, hint=None):
import os.path
self.path = path
hint = "" if hint is None else f". {hint}"
path_typ = "directory" if os.path.isdir(path) else "file"
msg = f"The {path_typ} '{path}' already exists locally{hint}"
super().__init__(msg)
self.errno = errno.EEXIST
class FileMissingError(DvcException):
def __init__(self, path, hint=None):
self.path = path
+18 -6
View File
@@ -32,6 +32,7 @@ def get_dvc_info():
f"Platform: Python {platform.python_version()} on {platform.platform()}",
f"Subprojects:{_get_subprojects()}",
f"Supports:{_get_supported_remotes()}",
f"Config:{_get_config_dirs()}",
]
try:
@@ -42,7 +43,7 @@ def get_dvc_info():
# `dvc config cache.shared group`.
if os.path.exists(repo.cache.local.path):
info.append(f"Cache types: {_get_linktype_support_info(repo)}")
fs_type = get_fs_type(repo.cache.local.path)
fs_type = _get_fs_type(repo.cache.local.path)
info.append(f"Cache directory: {fs_type}")
else:
info.append("Cache types: " + error_link("no-dvc-cache"))
@@ -51,7 +52,7 @@ def get_dvc_info():
info.append(f"Remotes: {_get_remotes(repo.config)}")
root_directory = repo.root_dir
fs_root = get_fs_type(os.path.abspath(root_directory))
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}")
@@ -136,7 +137,18 @@ def _get_supported_remotes():
return "\n\t" + ",\n\t".join(supported_remotes)
def get_fs_type(path):
def _get_config_dirs():
from dvc.config import Config
dirs = [
f"Global: {Config.get_dir('global')}",
f"System: {Config.get_dir('system')}",
]
return "\n\t" + "\n\t".join(dirs)
def _get_fs_type(path):
partition = {}
for part in psutil.disk_partitions(all=True):
if part.fstype:
@@ -155,11 +167,11 @@ def get_fs_type(path):
return ("unknown", "none")
def _get_dvc_repo_info(self):
if self.config.get("core", {}).get("no_scm", False):
def _get_dvc_repo_info(repo):
if repo.config.get("core", {}).get("no_scm", False):
return "dvc (no_scm)"
if self.root_dir != self.scm.root_dir:
if repo.root_dir != repo.scm.root_dir:
return "dvc (subdir), git"
return "dvc, git"
+201 -24
View File
@@ -1,10 +1,11 @@
import errno
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 typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type, Union
from urllib.parse import urlparse
from funcy import collecting, first, project
@@ -25,26 +26,25 @@ from dvc_data.hashfile import check as ocheck
from dvc_data.hashfile import load as oload
from dvc_data.hashfile.build import build
from dvc_data.hashfile.checkout import checkout
from dvc_data.hashfile.db import HashFileDB, add_update_tree
from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.hashfile.istextfile import istextfile
from dvc_data.hashfile.meta import Meta
from dvc_data.hashfile.transfer import transfer as otransfer
from dvc_data.hashfile.tree import Tree
from dvc_data.hashfile.tree import Tree, du
from dvc_objects.errors import ObjectFormatError
from .annotations import ANNOTATION_FIELDS, ANNOTATION_SCHEMA, Annotation
from .fs import LocalFileSystem, RemoteMissingDepsError, Schemes, get_cloud_fs
from .fs.callbacks import DEFAULT_CALLBACK
from .fs.callbacks import DEFAULT_CALLBACK, Callback, TqdmCallback
from .utils import relpath
from .utils.fs import path_isin
if TYPE_CHECKING:
from dvc_data.hashfile.db import HashFileDB
from dvc_data.hashfile.obj import HashFile
from dvc_data.index import DataIndexKey
from dvc_objects.db import ObjectDB
from .fs.callbacks import Callback
from .ignore import DvcIgnoreFilter
logger = logging.getLogger(__name__)
@@ -276,6 +276,11 @@ class OutputIsIgnoredError(DvcException):
super().__init__(f"Path '{match.file}' is ignored by\n{lines}")
class CheckoutCallback(TqdmCallback):
# disable branching for checkouts
branch = Callback.branch # type: ignore[assignment]
class Output:
IS_DEPENDENCY = False
@@ -417,8 +422,6 @@ class Output:
def _compute_meta_hash_info_from_files(self) -> None:
if self.files:
from dvc_data.hashfile.db import HashFileDB
tree = Tree.from_list(self.files, hash_name=self.hash_name)
tree.digest(with_meta=True)
self.odb = HashFileDB(tree.fs, tree.path + ".odb")
@@ -741,15 +744,18 @@ class Output:
hardlink=hardlink,
)
if relink:
self._checkout(
filter_info or self.fs_path,
self.fs,
obj,
self.cache,
relink=True,
state=self.repo.state,
prompt=prompt.confirm,
)
rel = self.fs.path.relpath(filter_info or self.fs_path)
with CheckoutCallback(desc=f"Checking out {rel}", unit="files") as cb:
self._checkout(
filter_info or self.fs_path,
self.fs,
obj,
self.cache,
relink=True,
state=self.repo.state,
prompt=prompt.confirm,
progress_callback=cb,
)
self.set_exec()
def _commit_granular_dir(self, filter_info, hardlink) -> Optional["HashFile"]:
@@ -891,9 +897,16 @@ class Output:
checkpoint_reset: bool = False,
**kwargs,
) -> Optional[Tuple[bool, Optional[bool]]]:
# callback passed act as a aggregate callback.
# do not let checkout to call set_size and change progressbar.
class CallbackProxy(Callback):
def relative_update(self, inc: int = 1) -> None:
progress_callback.relative_update(inc)
return super().relative_update(inc)
callback = CallbackProxy()
if not self.use_cache:
if progress_callback != DEFAULT_CALLBACK:
progress_callback.relative_update(self.get_files_number(filter_info))
callback.relative_update(self.get_files_number(filter_info))
return None
obj = self.get_obj(filter_info=filter_info)
@@ -915,7 +928,7 @@ class Output:
obj,
self.cache,
force=force,
progress_callback=progress_callback,
progress_callback=callback,
relink=relink,
state=self.repo.state,
prompt=prompt.confirm,
@@ -1008,7 +1021,7 @@ class Output:
if self.exists:
self.cache.unprotect(self.fs_path)
def get_dir_cache(self, **kwargs):
def get_dir_cache(self, **kwargs) -> Optional["Tree"]:
if not self.is_dir_checksum:
raise DvcException("cannot get dir cache for file checksum")
@@ -1022,14 +1035,17 @@ class Output:
self.repo.cloud.pull([obj.hash_info], **kwargs)
if self.obj:
assert isinstance(self.obj, Tree)
return self.obj
try:
self.obj = oload(self.cache, self.hash_info)
obj = oload(self.cache, self.hash_info)
assert isinstance(obj, Tree)
except (FileNotFoundError, ObjectFormatError):
self.obj = None
obj = None
return self.obj
self.obj = obj
return obj
def _collect_used_dir_cache(
self, remote=None, force=False, jobs=None, filter_info=None
@@ -1165,7 +1181,7 @@ class Output:
def merge(self, ancestor, other, allowed=None):
from dvc_data.hashfile.tree import MergeError as TreeMergeError
from dvc_data.hashfile.tree import du, merge
from dvc_data.hashfile.tree import merge
assert other
@@ -1198,6 +1214,167 @@ class Output:
nfiles=len(merged),
)
def unstage(self, path: str) -> Tuple["Meta", "Tree"]:
from pygtrie import Trie
from dvc_objects.fs.path import Path
assert isinstance(self.fs.path, Path)
rel_key = tuple(self.fs.path.parts(self.fs.path.relpath(path, self.fs_path)))
if self.hash_info:
tree = self.get_dir_cache()
if tree is None:
raise DvcException(f"could not read {self.hash_info.value!r}")
else:
tree = Tree()
trie = tree.as_trie()
assert isinstance(trie, Trie)
try:
del trie[rel_key:] # type: ignore[misc]
except KeyError:
raise FileNotFoundError( # noqa: B904
errno.ENOENT,
os.strerror(errno.ENOENT),
self.fs.path.relpath(path),
)
new = tree.from_trie(trie)
new.digest()
return Meta(nfiles=len(new), isdir=True), new
def apply(
self,
path: str,
obj: Union["Tree", "HashFile"],
meta: "Meta",
) -> Tuple["Meta", "Tree"]:
from pygtrie import Trie
from dvc_objects.fs.path import Path
assert isinstance(self.fs.path, Path)
append_only = True
rel_key = tuple(self.fs.path.parts(self.fs.path.relpath(path, self.fs_path)))
if self.hash_info:
tree = self.get_dir_cache()
if tree is None:
raise DvcException(f"could not read {self.hash_info.value!r}")
else:
tree = Tree()
trie = tree.as_trie()
assert isinstance(trie, Trie)
try:
del trie[rel_key:] # type: ignore[misc]
except KeyError:
pass
else:
append_only = False
items = {}
if isinstance(obj, Tree):
items = {(*rel_key, *key): (m, o) for key, m, o in obj}
else:
items = {rel_key: (meta, obj.hash_info)}
trie.update(items)
new = Tree.from_trie(trie)
new.digest()
size = self.meta.size if self.meta and self.meta.size else None
if append_only and size and meta.size is not None:
# if files were only appended, we can sum to the existing size
size += meta.size
elif self.hash_info and self.hash_info == new.hash_info:
# if hashes are same, sizes must have been the same
size = self.meta.size
else:
size = None
meta = Meta(nfiles=len(new), size=size, isdir=True)
return meta, new
def add( # noqa: C901
self, path: Optional[str] = None, no_commit: bool = False, relink: bool = True
) -> Optional["HashFile"]:
path = path or self.fs_path
if self.hash_info and not self.is_dir_checksum and self.fs_path != path:
raise DvcException(
f"Cannot modify '{self}' which is being tracked as a file"
)
assert self.repo
cache = self.cache if self.use_cache else self.repo.cache.local
assert isinstance(cache, HashFileDB)
new: "HashFile"
try:
assert self.hash_name
staging, meta, obj = build(
cache,
path,
self.fs,
self.hash_name,
ignore=self.dvcignore,
dry_run=not self.use_cache,
)
except FileNotFoundError as exc:
if self.fs_path == path:
raise self.DoesNotExistError(self) from exc
if not self.is_dir_checksum:
raise
meta, new = self.unstage(path)
staging, obj = None, None
else:
assert obj
assert staging
if self.fs_path != path:
meta, new = self.apply(path, obj, meta)
add_update_tree(staging, new)
else:
new = obj
self.obj = new
self.hash_info = self.obj.hash_info
self.meta = meta
self.files = None
self.ignore()
if no_commit or not self.use_cache:
return obj
if isinstance(new, Tree):
add_update_tree(cache, new)
if not obj:
return obj
assert staging
assert obj.hash_info
otransfer(staging, self.cache, {obj.hash_info}, hardlink=relink, shallow=False)
if relink:
rel = self.fs.path.relpath(path)
with CheckoutCallback(desc=f"Checking out {rel}", unit="files") as callback:
self._checkout(
path,
self.fs,
obj,
self.cache,
relink=True,
state=self.repo.state,
prompt=prompt.confirm,
progress_callback=callback,
)
self.set_exec()
return obj
@property
def fspath(self):
return self.fs_path
+4 -15
View File
@@ -287,18 +287,9 @@ class Repo:
@staticmethod
def open(url, *args, **kwargs): # noqa: A003
if url is None:
url = os.getcwd()
from .open_repo import open_repo
if os.path.exists(url):
try:
return Repo(url, *args, **kwargs)
except NotDvcRepoError:
pass # fallthrough to external_repo
from dvc.external_repo import external_repo
return external_repo(url, *args, **kwargs)
return open_repo(url, *args, **kwargs)
@cached_property
def scm(self) -> Union["Git", "NoSCM"]:
@@ -563,12 +554,10 @@ class Repo:
import getpass
import hashlib
import platformdirs
from dvc.dirs import site_cache_dir
from dvc.fs import GitFileSystem
default = platformdirs.site_cache_dir("dvc", "iterative", opinion=True)
cache_dir = self.config["core"].get("site_cache_dir") or default
cache_dir = self.config["core"].get("site_cache_dir") or site_cache_dir()
if isinstance(self.fs, GitFileSystem):
relparts = ()
+62 -29
View File
@@ -2,14 +2,16 @@ import logging
import os
from contextlib import contextmanager
from itertools import tee
from typing import TYPE_CHECKING, Any, Iterator, List, Optional
from typing import TYPE_CHECKING, Any, Iterator, List, NamedTuple, Optional
import colorama
from dvc.exceptions import (
CacheLinkError,
DvcException,
InvalidArgumentError,
OutputDuplicationError,
OutputNotFoundError,
OverlappingOutputPathsError,
RecursiveAddingWhileUsingFilename,
)
@@ -29,13 +31,25 @@ Stages = List["Stage"]
logger = logging.getLogger(__name__)
OVERLAPPING_OUTPUT_FMT = (
class StageInfo(NamedTuple):
stage: "Stage"
output_exists: bool
OVERLAPPING_CHILD_FMT = (
"Cannot add '{out}', because it is overlapping with other "
"DVC tracked output: '{parent}'.\n"
"To include '{out}' in '{parent}', run "
"'dvc commit {parent_stage}'"
)
OVERLAPPING_PARENT_FMT = (
"Cannot add '{parent}', because it is overlapping with other "
"DVC tracked output: '{out}'.\n"
"To include '{out}' in '{parent}', run "
"'dvc remove {out_stage}' and then 'dvc add {parent}'"
)
def check_recursive_and_fname(args):
if args.recursive and args.fname:
@@ -86,11 +100,18 @@ def translate_graph_error(stages: Stages) -> Iterator[None]:
try:
yield
except OverlappingOutputPathsError as exc:
msg = OVERLAPPING_OUTPUT_FMT.format(
out=exc.overlapping_out.fs_path,
parent=exc.parent.fs_path,
parent_stage=exc.parent.stage.addressing,
)
if exc.parent in [o for s in stages for o in s.outs]:
msg = OVERLAPPING_PARENT_FMT.format(
out=exc.overlapping_out,
parent=exc.parent,
out_stage=exc.overlapping_out.stage.addressing,
)
else:
msg = OVERLAPPING_CHILD_FMT.format(
out=exc.overlapping_out,
parent=exc.parent,
parent_stage=exc.parent.stage.addressing,
)
raise OverlappingOutputPathsError( # noqa: B904
exc.parent, exc.overlapping_out, msg
)
@@ -100,7 +121,7 @@ def translate_graph_error(stages: Stages) -> Iterator[None]:
)
def progress_iter(stages: Stages) -> Iterator["Stage"]:
def progress_iter(stages: List[StageInfo]) -> Iterator["StageInfo"]:
total = len(stages)
desc = "Adding..."
with ui.progress(stages, total=total, desc=desc, unit="file", leave=True) as pbar:
@@ -108,10 +129,10 @@ def progress_iter(stages: Stages) -> Iterator["Stage"]:
pbar.bar_format = desc
pbar.refresh()
for stage in pbar:
for item in pbar:
if total > 1:
pbar.set_msg(f"{stage.outs[0]}")
yield stage
pbar.set_msg(f"{item.stage.outs[0]}")
yield item
if total == 1: # restore bar format for stats
# pylint: disable=no-member
pbar.bar_format = pbar.BAR_FMT_DEFAULT
@@ -169,12 +190,14 @@ def add(
desc = "Collecting targets"
stages_it = create_stages(repo, add_targets, fname, transfer, **kwargs)
stages = list(ui.progress(stages_it, desc=desc, unit="file"))
stages_list = [stage for stage, _ in stages]
msg = "Collecting stages from the workspace"
with translate_graph_error(stages), ui.status(msg) as status:
with translate_graph_error(stages_list), ui.status(msg) as status:
# remove existing stages that are to-be replaced with these
# new stages for the graph checks.
repo.check_graph(
stages=stages, callback=lambda: status.update("Checking graph")
stages=stages_list, callback=lambda: status.update("Checking graph")
)
odb = None
@@ -182,18 +205,18 @@ def add(
odb = repo.cloud.get_remote_odb(kwargs.get("remote"), "add")
with warn_link_failures() as link_failures:
for stage, source in zip(progress_iter(stages), sources):
for (stage, output_exists), source in zip(progress_iter(stages), sources):
out = stage.outs[0]
if to_remote or to_cache:
stage.transfer(source, to_remote=to_remote, odb=odb, **kwargs)
else:
try:
stage.save()
if not no_commit:
stage.commit()
path = out.fs.path.abspath(source) if output_exists else None
stage.add_outs(path, no_commit=no_commit)
except CacheLinkError:
link_failures.append(str(stage.relpath))
stage.dump()
return stages
return stages_list
LARGE_DIR_RECURSIVE_ADD_WARNING = (
@@ -247,24 +270,34 @@ def create_stages(
fname: Optional[str] = None,
transfer: bool = False,
external: bool = False,
force: bool = False,
**kwargs: Any,
) -> Iterator["Stage"]:
) -> Iterator[StageInfo]:
for target in targets:
if kwargs.get("out"):
target = resolve_output(target, kwargs["out"])
target = resolve_output(target, kwargs["out"], force=force)
path, wdir, out = resolve_paths(
repo, target, always_local=transfer and not kwargs.get("out")
)
stage = repo.stage.create(
single_stage=True,
validate=False,
fname=fname or path,
wdir=wdir,
outs=[out],
external=external,
)
try:
(out_obj,) = repo.find_outs_by_path(target, strict=False)
stage = out_obj.stage
if not stage.is_data_source:
raise DvcException(f"cannot update {out!r}: not a data source")
output_exists = True
except OutputNotFoundError:
stage = repo.stage.create(
single_stage=True,
validate=False,
fname=fname or path,
wdir=wdir,
outs=[out],
external=external,
force=force,
)
output_exists = False
out_obj = stage.outs[0]
out_obj.annot.update(**kwargs)
yield stage
yield StageInfo(stage, output_exists)
+49 -29
View File
@@ -1,19 +1,26 @@
import logging
import os
import re
from typing import TYPE_CHECKING, Dict
from pathlib import Path
from typing import Dict, Optional
from dvc.annotations import Artifact
from dvc.dvcfile import FileMixin
from dvc.dvcfile import PROJECT_FILE
from dvc.exceptions import InvalidArgumentError
from dvc.repo import Repo
from dvc.utils import relpath
if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.utils.serialize import modify_yaml
logger = logging.getLogger(__name__)
NAME_RE = re.compile(r"^[a-z]([a-z0-9-]*[a-z0-9])?$")
# Constants are taken from GTO.
# When we make it a dependency, we can import them instead
SEPARATOR_IN_NAME = ":"
DIRNAME = r"[a-z0-9-_./]+"
NAME = r"[a-z0-9]([a-z0-9-/]*[a-z0-9])?"
NAME_RE = re.compile(f"^{NAME}$")
FULLNAME = f"((?P<dirname>{DIRNAME}){SEPARATOR_IN_NAME})?(?P<name>{NAME})"
FULLNAME_RE = re.compile(f"^{FULLNAME}$")
def name_is_compatible(name: str) -> bool:
@@ -22,22 +29,23 @@ def name_is_compatible(name: str) -> bool:
def check_name_format(name: str) -> None:
if not name_is_compatible(name):
logger.warning(
"Can't use '%s' as artifact name (ID)."
raise InvalidArgumentError(
f"Can't use '{name}' as artifact name (ID)."
" You can use letters and numbers, and use '-' as separator"
" (but not at the start or end). The first character must be a letter.",
name,
" (but not at the start or end)."
)
class ArtifactsFile(FileMixin):
from dvc.schema import SINGLE_ARTIFACT_SCHEMA as SCHEMA
def dump(self, stage, **kwargs):
raise NotImplementedError
def merge(self, ancestor, other, allowed=None):
raise NotImplementedError
def check_for_nested_dvc_repo(dvcfile: Path):
if dvcfile.is_absolute():
raise InvalidArgumentError("Use relative path to dvc.yaml.")
path = dvcfile.parent
while path.name:
if (path / Repo.DVC_DIR).is_dir():
raise InvalidArgumentError(
f"Nested DVC repos like {path} are not supported."
)
path = path.parent
class Artifacts:
@@ -50,18 +58,30 @@ class Artifacts:
dvcfile,
dvcfile_artifacts,
) in self.repo.index._artifacts.items(): # pylint: disable=protected-access
# read the artifacts.yaml file if needed
if isinstance(dvcfile_artifacts, str):
dvcfile_artifacts = ArtifactsFile(
self.repo,
os.path.join(os.path.dirname(dvcfile), dvcfile_artifacts),
verify=False,
).load()
if not dvcfile_artifacts:
continue
dvcyaml = relpath(dvcfile, self.repo.root_dir)
artifacts[dvcyaml] = {}
for name, value in dvcfile_artifacts.items():
check_name_format(name)
try:
check_name_format(name)
except InvalidArgumentError as e:
logger.warning(e.msg)
artifacts[dvcyaml][name] = Artifact(**value)
return artifacts
def add(self, name: str, artifact: Artifact, dvcfile: Optional[str] = None):
with self.repo.scm_context(quiet=True):
check_name_format(name)
dvcyaml = Path(dvcfile or PROJECT_FILE)
check_for_nested_dvc_repo(
dvcyaml.relative_to(self.repo.root_dir)
if dvcyaml.is_absolute()
else dvcyaml
)
with modify_yaml(dvcyaml) as data:
artifacts = data.setdefault("artifacts", {})
artifacts.update({name: artifact.to_dict()})
self.repo.scm_context.track_file(dvcfile)
return artifacts.get(name)
-1
View File
@@ -172,7 +172,6 @@ def _diff_index_to_wtree(repo: "Repo", **kwargs: Any) -> Dict[str, List[str]]:
repo.index.data["repo"],
workspace,
not_in_cache=True,
not_in_remote=True,
**kwargs,
)
+10 -14
View File
@@ -119,6 +119,7 @@ class Experiments:
self,
tmp_dir: bool = False,
copy_paths: Optional[List[str]] = None,
message: Optional[str] = None,
**kwargs,
):
"""Reproduce and checkout a single (standalone) experiment."""
@@ -126,7 +127,9 @@ class Experiments:
self.tempdir_queue if tmp_dir else self.workspace_queue
)
self.queue_one(exp_queue, **kwargs)
results = self._reproduce_queue(exp_queue, copy_paths=copy_paths)
results = self._reproduce_queue(
exp_queue, copy_paths=copy_paths, message=message
)
exp_rev = first(results)
if exp_rev is not None:
self._log_reproduced(results, tmp_dir=tmp_dir)
@@ -349,7 +352,11 @@ class Experiments:
@unlocked_repo
def _reproduce_queue(
self, queue: "BaseStashQueue", copy_paths: Optional[List[str]] = None, **kwargs
self,
queue: "BaseStashQueue",
copy_paths: Optional[List[str]] = None,
message: Optional[str] = None,
**kwargs,
) -> Dict[str, str]:
"""Reproduce queued experiments.
@@ -360,7 +367,7 @@ class Experiments:
dict mapping successfully reproduced experiment revs to their
results.
"""
exec_results = queue.reproduce(copy_paths=copy_paths)
exec_results = queue.reproduce(copy_paths=copy_paths, message=message)
results: Dict[str, str] = {}
for _, exp_result in exec_results.items():
@@ -442,17 +449,6 @@ class Experiments:
result[rev] = name
return result
def get_running_exps(self, fetch_refs: bool = True) -> Dict[str, Dict]:
"""Return info for running experiments."""
result = {}
for queue in (
self.workspace_queue,
self.tempdir_queue,
self.celery_queue,
):
result.update(queue.get_running_exps(fetch_refs))
return result
def apply(self, *args, **kwargs):
from dvc.repo.experiments.apply import apply
+47
View File
@@ -0,0 +1,47 @@
from contextlib import ExitStack, contextmanager
from typing import TYPE_CHECKING, Iterator, Tuple
from dvc.repo.experiments.exceptions import InvalidExpRevError
from dvc.scm import RevError
if TYPE_CHECKING:
from dvc.repo import Repo
@contextmanager
def switch_repo(
repo: "Repo",
rev: str,
) -> Iterator[Tuple["Repo", str]]:
"""Return a repo instance (brancher) switched to rev.
If rev is the name of a running experiment, the returned instance will be
the live repo wherever the experiment is running.
NOTE: This will not resolve git SHA's that only exist in queued exp workspaces
(it will only match queued exp names).
"""
try:
with repo.switch(rev):
yield repo, rev
return
except RevError as exc:
orig_exc = exc
exps = repo.experiments
if rev == exps.workspace_queue.get_running_exp():
yield repo, "workspace"
return
for queue in (exps.tempdir_queue, exps.celery_queue):
try:
active_repo = queue.active_repo(rev)
except InvalidExpRevError:
continue
stack = ExitStack()
stack.enter_context(active_repo)
stack.enter_context(active_repo.switch("workspace"))
with stack:
yield active_repo, rev
return
raise orig_exc
+380
View File
@@ -0,0 +1,380 @@
import itertools
import logging
import os
from datetime import datetime
from typing import (
TYPE_CHECKING,
Collection,
Dict,
Iterable,
Iterator,
List,
Optional,
Union,
)
from funcy import first
from scmrepo.exceptions import SCMError as InnerSCMError
from dvc.scm import Git, SCMError, iter_revs
from .exceptions import InvalidExpRefError
from .refs import EXEC_BRANCH, ExpRefInfo
from .serialize import ExpRange, ExpState, SerializableError, SerializableExp
if TYPE_CHECKING:
from dvc.repo import Repo
from .cache import ExpCache
logger = logging.getLogger(__name__)
def collect_rev(
repo: "Repo",
rev: str,
param_deps: bool = False,
force: bool = False,
cache: Optional["ExpCache"] = None,
**kwargs,
) -> ExpState:
"""Collect experiment state for the given revision.
Exp will be loaded from cache when available unless rev is 'workspace' or
force is set.
"""
from dvc.fs import LocalFileSystem
cache = cache or repo.experiments.cache
assert cache
# TODO: support filtering serialized exp when param_deps is set
if rev != "workspace" and not (force or param_deps):
cached_exp = cache.get(rev)
if cached_exp:
if isinstance(cached_exp, SerializableError):
return ExpState(rev=rev, error=cached_exp)
return ExpState(rev=rev, data=cached_exp)
if rev == "workspace" and isinstance(repo.fs, LocalFileSystem):
orig_cwd: Optional[str] = os.getcwd()
os.chdir(repo.root_dir)
else:
orig_cwd = None
try:
data = _collect_rev(
repo,
rev,
param_deps=param_deps,
force=force,
**kwargs,
)
if not (rev == "workspace" or param_deps or data.contains_error):
cache.put(data, force=True)
return ExpState(rev=rev, data=data)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
logger.debug("", exc_info=True)
error = SerializableError(str(exc), type(exc).__name__)
return ExpState(rev=rev, error=error)
finally:
if orig_cwd:
os.chdir(orig_cwd)
def _collect_rev(
repo: "Repo",
rev: str,
param_deps: bool = False,
**kwargs,
) -> SerializableExp:
with repo.switch(rev) as rev:
if rev == "workspace":
timestamp: Optional[datetime] = None
else:
commit = repo.scm.resolve_commit(rev)
timestamp = datetime.fromtimestamp(commit.commit_time)
return SerializableExp.from_repo(
repo,
rev=rev,
param_deps=param_deps,
timestamp=timestamp,
)
def collect_branch(
repo: "Repo",
rev: str,
end_rev: Optional[str] = None,
**kwargs,
) -> Iterator["ExpState"]:
"""Iterate over exp states in a Git branch.
Git branch will be traversed in reverse, starting from rev.
Args:
rev: Branch tip (head).
end_rev: If specified, traversal will stop when end_rev is reached
(exclusive, end_rev will not be collected).
"""
try:
for branch_rev in repo.scm.branch_revs(rev, end_rev):
yield collect_rev(repo, branch_rev, **kwargs)
except (SCMError, InnerSCMError):
pass
def collect_exec_branch(
repo: "Repo",
baseline_rev: str,
**kwargs,
) -> Iterator["ExpState"]:
"""Iterate over active experiment branch for the current executor."""
last_rev = repo.scm.get_ref(EXEC_BRANCH) or repo.scm.get_rev()
last_rev = repo.scm.get_rev()
yield collect_rev(repo, "workspace", **kwargs)
if last_rev != baseline_rev:
yield from collect_branch(repo, last_rev, baseline_rev, **kwargs)
def collect_queued(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect queued experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to list of queued experiments.
"""
if not baseline_revs:
return {}
return repo.experiments.celery_queue.collect_queued_data(baseline_revs, **kwargs)
def collect_active(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect active (running) experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to list of active experiments.
"""
if not baseline_revs:
return {}
result: Dict[str, List["ExpRange"]] = {}
exps = repo.experiments
for queue in (exps.workspace_queue, exps.tempdir_queue, exps.celery_queue):
for baseline, active_exps in queue.collect_active_data(
baseline_revs, **kwargs
).items():
if baseline in result:
result[baseline].extend(active_exps)
else:
result[baseline] = list(active_exps)
return result
def collect_failed(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect failed experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to list of active experiments.
"""
if not baseline_revs:
return {}
return repo.experiments.celery_queue.collect_failed_data(baseline_revs, **kwargs)
def collect_successful(
repo: "Repo",
baseline_revs: Collection[str],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect successful experiments derived from the specified revisions.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Returns:
Dict mapping baseline revision to successful experiments.
"""
result: Dict[str, List["ExpRange"]] = {}
for baseline_rev in baseline_revs:
result[baseline_rev] = list(_collect_baseline(repo, baseline_rev, **kwargs))
return result
def _collect_baseline(
repo: "Repo",
baseline_rev: str,
**kwargs,
) -> Iterator["ExpRange"]:
"""Iterate over experiments derived from a baseline revision.
Args:
repo: Repo.
baseline_revs: Resolved baseline Git SHAs.
Yields:
Tuple of (timestamp, exp_range).
"""
ref_info = ExpRefInfo(baseline_sha=baseline_rev)
refs: Optional[Iterable[str]] = kwargs.get("refs")
if refs:
ref_it = (ref for ref in iter(refs) if ref.startswith(str(ref_info)))
else:
ref_it = repo.scm.iter_refs(base=str(ref_info))
for ref in ref_it:
try:
ref_info = ExpRefInfo.from_ref(ref)
exp_rev = repo.scm.get_ref(ref)
if not exp_rev:
continue
except (InvalidExpRefError, SCMError, InnerSCMError):
continue
exps = list(collect_branch(repo, exp_rev, baseline_rev, **kwargs))
if exps:
exps[0].name = ref_info.name
yield ExpRange(exps, name=ref_info.name)
def collect(
repo: "Repo",
revs: Union[List[str], str, None] = None,
all_branches: bool = False,
all_tags: bool = False,
all_commits: bool = False,
num: int = 1,
hide_queued: bool = False,
hide_failed: bool = False,
sha_only: bool = False,
**kwargs,
) -> List["ExpState"]:
"""Collect baseline revisions and derived experiments."""
assert isinstance(repo.scm, Git)
if repo.scm.no_commits:
return []
if not any([revs, all_branches, all_tags, all_commits]):
revs = ["HEAD"]
if isinstance(revs, str):
revs = [revs]
cached_refs = list(repo.scm.iter_refs())
baseline_revs = list(
iter_revs(
repo.scm,
revs=revs,
num=num,
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
)
)
if sha_only:
baseline_names: Dict[str, Optional[str]] = {}
else:
baseline_names = _describe(repo.scm, baseline_revs, refs=cached_refs)
workspace_data = collect_rev(repo, "workspace", **kwargs)
result: List["ExpState"] = [workspace_data]
queued = collect_queued(repo, baseline_revs, **kwargs) if not hide_queued else {}
active = collect_active(repo, baseline_revs, **kwargs)
failed = collect_failed(repo, baseline_revs, **kwargs) if not hide_failed else {}
successful = collect_successful(repo, baseline_revs, **kwargs)
for baseline_rev in baseline_revs:
baseline_data = collect_rev(repo, baseline_rev)
experiments = list(
itertools.chain.from_iterable(
_sorted_ranges(collected.get(baseline_rev, []))
for collected in (active, successful, queued, failed)
)
)
result.append(
ExpState(
rev=baseline_rev,
name=baseline_names.get(baseline_rev),
data=baseline_data.data,
error=baseline_data.error,
experiments=experiments if experiments else None,
)
)
return result
def _describe(
scm: "Git",
revs: Iterable[str],
refs: Optional[Iterable[str]] = None,
) -> Dict[str, Optional[str]]:
"""Describe revisions using a tag, branch.
The first matching name will be returned for each rev. Names are preferred in this
order:
- current branch (if rev matches HEAD and HEAD is a branch)
- tags
- branches
Returns:
Dict mapping revisions from revs to a name.
"""
head_rev = scm.get_rev()
head_ref = scm.get_ref("HEAD", follow=False)
if head_ref and head_ref.startswith("refs/heads/"):
head_branch = head_ref[len("refs/heads/") :]
else:
head_branch = None
tags = {}
branches = {}
ref_it = iter(refs) if refs else scm.iter_refs()
for ref in ref_it:
is_tag = ref.startswith("refs/tags/")
is_branch = ref.startswith("refs/heads/")
if not (is_tag or is_branch):
continue
rev = scm.get_ref(ref)
if not rev:
logger.debug("unresolved ref %s", ref)
continue
if is_tag and rev not in tags:
tags[rev] = ref[len("refs/tags/") :]
if is_branch and rev not in branches:
branches[rev] = ref[len("refs/heads/") :]
names: Dict[str, Optional[str]] = {}
for rev in revs:
if rev == head_rev and head_branch:
names[rev] = head_branch
else:
names[rev] = tags.get(rev) or branches.get(rev)
return names
def _sorted_ranges(exp_ranges: Iterable["ExpRange"]) -> List["ExpRange"]:
"""Return list of ExpRange sorted by timestamp."""
def _head_timestamp(exp_range: "ExpRange") -> datetime:
head_exp = first(exp_range.revs)
if head_exp and head_exp.data and head_exp.data.timestamp:
return head_exp.data.timestamp
return datetime.fromtimestamp(0)
return sorted(exp_ranges, key=_head_timestamp, reverse=True)
+7 -8
View File
@@ -7,7 +7,7 @@ logger = logging.getLogger(__name__)
def diff(repo, *args, a_rev=None, b_rev=None, param_deps=False, **kwargs):
from dvc.repo.experiments.show import collect_experiment_commit
from dvc.repo.experiments.collect import collect_rev
from dvc.scm import resolve_rev
if repo.scm.no_commits:
@@ -15,22 +15,21 @@ def diff(repo, *args, a_rev=None, b_rev=None, param_deps=False, **kwargs):
if a_rev:
rev = resolve_rev(repo.scm, a_rev)
old = collect_experiment_commit(repo, rev, param_deps=param_deps)
else:
old = collect_experiment_commit(repo, "HEAD", param_deps=param_deps)
rev = resolve_rev(repo.scm, "HEAD")
old = collect_rev(repo, rev, param_deps=param_deps)
if b_rev:
rev = resolve_rev(repo.scm, b_rev)
new = collect_experiment_commit(repo, rev, param_deps=param_deps)
else:
new = collect_experiment_commit(repo, "workspace", param_deps=param_deps)
rev = "workspace"
new = collect_rev(repo, rev, param_deps=param_deps)
with_unchanged = kwargs.pop("all", False)
return {
key: _diff(
format_dict(old.get("data", {}).get(key, {})),
format_dict(new.get("data", {}).get(key, {})),
format_dict(getattr(old.data, key, {})),
format_dict(getattr(new.data, key, {})),
with_unchanged=with_unchanged,
)
for key in ["metrics", "params"]
+7
View File
@@ -114,3 +114,10 @@ class UnresolvedRunningExpNamesError(UnresolvedExpNamesError):
class ExpQueueEmptyError(DvcException):
pass
class ExpNotStartedError(DvcException):
def __init__(self, name: str):
super().__init__(
f"Queued experiment '{name}' exists but has not started running yet"
)
+47 -10
View File
@@ -7,12 +7,14 @@ from contextlib import contextmanager
from dataclasses import asdict, dataclass
from enum import IntEnum
from functools import partial
from itertools import chain
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
NamedTuple,
Optional,
@@ -41,6 +43,7 @@ from dvc.stage.serialize import to_lockfile
from dvc.ui import ui
from dvc.utils import dict_sha256, env2bool, relpath
from dvc.utils.fs import remove
from dvc.utils.studio import env_to_config
if TYPE_CHECKING:
from queue import Queue
@@ -259,12 +262,23 @@ class BaseExecutor(ABC):
**kwargs,
)
@classmethod
def _get_top_level_paths(cls, repo: "Repo") -> List["str"]:
return list(
chain(
_collect_top_level_metrics(repo),
_collect_top_level_params(repo),
repo.index._plot_sources, # pylint: disable=protected-access
)
)
@classmethod
def save(
cls,
info: "ExecutorInfo",
force: bool = False,
include_untracked: Optional[List[str]] = None,
message: Optional[str] = None,
) -> ExecutorResult:
from dvc.dvcfile import LOCK_FILE
from dvc.repo import Repo
@@ -280,11 +294,7 @@ class BaseExecutor(ABC):
os.chdir(dvc.root_dir)
include_untracked = include_untracked or []
include_untracked.extend(_collect_top_level_metrics(dvc))
include_untracked.extend(_collect_top_level_params(dvc))
include_untracked.extend(
dvc.index._plot_sources # pylint: disable=protected-access
)
include_untracked.extend(cls._get_top_level_paths(dvc))
# dvc repro automatically stages dvc.lock. Running redundant `git add`
# on it causes an error when exiting the detached head context.
if LOCK_FILE in dvc.scm.untracked_files():
@@ -300,6 +310,7 @@ class BaseExecutor(ABC):
exp_hash,
exp_name=info.name,
force=force,
message=message,
)
ref: Optional[str] = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
exp_ref = ExpRefInfo.from_ref(ref) if ref else None
@@ -453,6 +464,7 @@ class BaseExecutor(ABC):
log_errors: bool = True,
log_level: Optional[int] = None,
copy_paths: Optional[List[str]] = None,
message: Optional[str] = None,
**kwargs,
) -> "ExecutorResult":
"""Run dvc repro and return the result.
@@ -485,11 +497,15 @@ class BaseExecutor(ABC):
exp_ref: Optional["ExpRefInfo"] = None
repro_force: bool = False
if info.name:
ui.write(f"Reproducing experiment '{info.name}'")
with cls._repro_dvc(
info,
infofile,
log_errors=log_errors,
copy_paths=copy_paths,
message=message,
**kwargs,
) as dvc:
if auto_push:
@@ -540,6 +556,7 @@ class BaseExecutor(ABC):
info.name,
repro_force or checkpoint_reset,
)
stages = dvc_reproduce(
dvc,
*args,
@@ -547,6 +564,9 @@ class BaseExecutor(ABC):
checkpoint_func=checkpoint_func,
**kwargs,
)
if paths := cls._get_top_level_paths(dvc):
logger.debug("Staging top-level files: %s", paths)
dvc.scm_context.add(paths)
exp_hash = cls.hash_exp(stages)
if not repro_dry:
@@ -558,6 +578,7 @@ class BaseExecutor(ABC):
auto_push,
git_remote,
repro_force,
message=message,
)
info.result_hash = exp_hash
info.result_ref = ref
@@ -578,6 +599,7 @@ class BaseExecutor(ABC):
auto_push,
git_remote,
repro_force,
message: Optional[str] = None,
) -> Tuple[Optional[str], Optional["ExpRefInfo"], bool]:
is_checkpoint = any(stage.is_checkpoint for stage in stages)
cls.commit(
@@ -586,6 +608,7 @@ class BaseExecutor(ABC):
exp_name=info.name,
force=repro_force,
checkpoint=is_checkpoint,
message=message,
)
if auto_push:
cls._auto_push(dvc, dvc.scm, git_remote)
@@ -613,8 +636,9 @@ class BaseExecutor(ABC):
infofile: Optional[str] = None,
log_errors: bool = True,
copy_paths: Optional[List[str]] = None,
message: Optional[str] = None,
**kwargs,
):
) -> Iterator["Repo"]:
from dvc_studio_client.post_live_metrics import post_live_metrics
from dvc.repo import Repo
@@ -636,13 +660,23 @@ class BaseExecutor(ABC):
else:
os.chdir(dvc.root_dir)
args_path = os.path.join(dvc.tmp_dir, cls.PACKED_ARGS_FILE)
if os.path.exists(args_path):
_, kwargs = cls.unpack_repro_args(args_path)
dvc_studio_config = dvc.config.get("studio")
# set missing config options using saved config
# inferring repo url will fail if not set here
run_env_config = env_to_config(kwargs.get("run_env", {}))
dvc_studio_config = {**run_env_config, **dvc_studio_config}
try:
post_live_metrics(
"start",
info.baseline_rev,
info.name,
info.name, # type: ignore[arg-type]
"dvc",
params=to_studio_params(dvc.params.show()),
dvc_studio_config=dvc_studio_config,
message=message,
)
logger.debug("Running repro in '%s'", os.getcwd())
yield dvc
@@ -664,10 +698,11 @@ class BaseExecutor(ABC):
post_live_metrics(
"done",
info.baseline_rev,
info.name,
info.name, # type: ignore[arg-type]
"dvc",
experiment_rev=dvc.experiments.scm.get_ref(EXEC_BRANCH),
metrics=get_in(dvc.metrics.show(), ["", "data"]),
dvc_studio_config=dvc_studio_config,
)
if infofile is not None:
@@ -681,7 +716,7 @@ class BaseExecutor(ABC):
args, kwargs = cls.unpack_repro_args(args_path)
remove(args_path)
# explicitly git rm/unstage the args file
dvc.scm.add([args_path])
dvc.scm.add([args_path], force=True)
else:
args = []
kwargs = {}
@@ -739,6 +774,7 @@ class BaseExecutor(ABC):
exp_name: Optional[str] = None,
force: bool = False,
checkpoint: bool = False,
message: Optional[str] = None,
):
"""Commit stages as an experiment and return the commit SHA."""
rev = scm.get_rev()
@@ -768,7 +804,8 @@ class BaseExecutor(ABC):
logger.debug("Commit to new experiment branch '%s'", branch)
scm.add([], update=True)
scm.commit(f"dvc: commit experiment {exp_hash}", no_verify=True)
message = message or f"dvc: commit experiment {exp_hash}"
scm.commit(message, no_verify=True)
new_rev = scm.get_rev()
if check_conflict:
new_rev = cls._raise_ref_conflict(scm, branch, new_rev, checkpoint)
+4 -4
View File
@@ -17,7 +17,7 @@ from dvc.repo.experiments.refs import (
EXEC_HEAD,
EXEC_MERGE,
EXEC_NAMESPACE,
EXPS_TEMP,
TEMP_NAMESPACE,
)
from dvc.repo.experiments.utils import EXEC_TMP_DIR, get_exp_rwlock
from dvc.scm import SCM, Git
@@ -90,9 +90,9 @@ class TempDirExecutor(BaseLocalExecutor):
if infofile:
self.info.dump_json(infofile)
temp_head = f"{EXPS_TEMP}/head-{uuid()}"
temp_merge = f"{EXPS_TEMP}/merge-{uuid()}"
temp_baseline = f"{EXPS_TEMP}/baseline-{uuid()}"
temp_head = f"{TEMP_NAMESPACE}/head-{uuid()}"
temp_merge = f"{TEMP_NAMESPACE}/merge-{uuid()}"
temp_baseline = f"{TEMP_NAMESPACE}/baseline-{uuid()}"
temp_ref_dict = {
temp_head: entry.head_rev,
+1
View File
@@ -243,6 +243,7 @@ class SSHExecutor(BaseExecutor):
log_errors: bool = True,
log_level: Optional[int] = None,
copy_paths: Optional[List[str]] = None, # noqa: ARG003
message: Optional[str] = None, # noqa: ARG003
**kwargs,
) -> "ExecutorResult":
"""Reproduce an experiment on a remote machine over SSH.
+6 -3
View File
@@ -1,6 +1,6 @@
import logging
from collections import defaultdict
from typing import Optional
from typing import List, Optional, Union
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
@@ -15,14 +15,17 @@ logger = logging.getLogger(__name__)
@scm_context
def ls(
repo,
rev: Optional[str] = None,
rev: Optional[Union[List[str], str]] = None,
all_commits: bool = False,
num: int = 1,
git_remote: Optional[str] = None,
):
rev_set = None
if not all_commits:
revs = iter_revs(repo.scm, [rev or "HEAD"], num)
rev = rev or "HEAD"
if isinstance(rev, str):
rev = [rev]
revs = iter_revs(repo.scm, rev, num)
rev_set = set(revs.keys())
ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set, git_remote)
+4 -2
View File
@@ -23,7 +23,7 @@ def pull( # noqa: C901
git_remote: str,
exp_names: Union[Iterable[str], str],
all_commits=False,
rev: Optional[str] = None,
rev: Optional[Union[List[str], str]] = None,
num=1,
force: bool = False,
pull_cache: bool = False,
@@ -49,7 +49,9 @@ def pull( # noqa: C901
raise UnresolvedExpNamesError(unresolved_exp_names)
if rev:
rev_dict = iter_revs(repo.scm, [rev], num)
if isinstance(rev, str):
rev = [rev]
rev_dict = iter_revs(repo.scm, rev, num)
rev_set = set(rev_dict.keys())
ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set, git_remote)
for _, ref_info_list in ref_info_dict.items():
+53 -41
View File
@@ -14,11 +14,13 @@ from typing import (
from funcy import compact, group_by
from scmrepo.git.backend.base import SyncStatus
from dvc.env import DVC_STUDIO_TOKEN, DVC_STUDIO_URL
from dvc.exceptions import DvcException
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm import Git, TqdmGit, iter_revs
from dvc.utils import env2bool
from dvc.utils.collections import ensure_list
from .exceptions import UnresolvedExpNamesError
from .refs import ExpRefInfo
@@ -39,21 +41,18 @@ class UploadError(DvcException):
def notify_refs_to_studio(
repo: "Repo", git_remote: str, **refs: List[str]
) -> Optional[str]:
config = repo.config["feature"]
import os
config = repo.config["studio"]
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")
token = (
os.environ.get(DVC_STUDIO_TOKEN)
or os.environ.get("STUDIO_TOKEN")
or config.get("token")
)
if not token:
logger.debug("Studio token not found.")
return None
@@ -63,19 +62,44 @@ def notify_refs_to_studio(
from dvc.utils import studio
_, repo_url = get_remote_repo(repo.scm.dulwich.repo, git_remote)
studio_url = config.get("studio_url")
studio_url = os.environ.get(DVC_STUDIO_URL) or config.get("url")
d = studio.notify_refs(repo_url, token, base_url=studio_url, **refs)
return d.get("url")
def exp_refs_from_names(scm: "Git", exp_names: List[str]) -> Set["ExpRefInfo"]:
exp_ref_set = set()
exp_ref_dict = resolve_name(scm, exp_names)
unresolved_exp_names = []
for exp_name, exp_ref in exp_ref_dict.items():
if exp_ref is None:
unresolved_exp_names.append(exp_name)
else:
exp_ref_set.add(exp_ref)
if unresolved_exp_names:
raise UnresolvedExpNamesError(unresolved_exp_names)
return exp_ref_set
def exp_refs_from_rev(scm: "Git", rev: List[str], num: int = 1) -> Set["ExpRefInfo"]:
exp_ref_set = set()
rev_dict = iter_revs(scm, rev, num)
rev_set = set(rev_dict.keys())
ref_info_dict = exp_refs_by_baseline(scm, rev_set)
for _, ref_info_list in ref_info_dict.items():
exp_ref_set.update(ref_info_list)
return exp_ref_set
@locked
@scm_context
def push( # noqa: C901
def push(
repo: "Repo",
git_remote: str,
exp_names: Union[Iterable[str], str],
exp_names: Union[List[str], str],
all_commits: bool = False,
rev: Optional[str] = None,
rev: Optional[Union[List[str], str]] = None,
num: int = 1,
force: bool = False,
push_cache: bool = False,
@@ -85,29 +109,12 @@ def push( # noqa: C901
assert isinstance(repo.scm, Git)
if all_commits:
exp_ref_set.update(exp_refs(repo.scm))
else:
if exp_names:
if isinstance(exp_names, str):
exp_names = [exp_names]
exp_ref_dict = resolve_name(repo.scm, exp_names)
unresolved_exp_names = []
for exp_name, exp_ref in exp_ref_dict.items():
if exp_ref is None:
unresolved_exp_names.append(exp_name)
else:
exp_ref_set.add(exp_ref)
if unresolved_exp_names:
raise UnresolvedExpNamesError(unresolved_exp_names)
if rev:
rev_dict = iter_revs(repo.scm, [rev], num)
rev_set = set(rev_dict.keys())
ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set)
for _, ref_info_list in ref_info_dict.items():
exp_ref_set.update(ref_info_list)
if exp_names:
exp_ref_set.update(exp_refs_from_names(repo.scm, ensure_list(exp_names)))
if rev:
if isinstance(rev, str):
rev = [rev]
exp_ref_set.update(exp_refs_from_rev(repo.scm, rev, num=num))
push_result = _push(repo, git_remote, exp_ref_set, force)
@@ -120,15 +127,20 @@ def push( # noqa: C901
pushed_refs_info = (
push_result[SyncStatus.UP_TO_DATE] + push_result[SyncStatus.SUCCESS]
)
e = None
if push_cache:
try:
result["uploaded"] = _push_cache(repo, pushed_refs_info, **kwargs)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
raise UploadError("failed to push cache", result) from exc
e = exc
pushed_refs = [str(r) for r in pushed_refs_info]
url = notify_refs_to_studio(repo, git_remote, pushed=pushed_refs)
return {**result, "url": url}
result["url"] = notify_refs_to_studio(repo, git_remote, pushed=pushed_refs)
if e:
raise UploadError("failed to push cache", result) from e
return result
def _push(
+82 -11
View File
@@ -17,8 +17,7 @@ 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 dvc_studio_client.post_live_metrics import get_studio_config
from funcy import retry
from dvc.dependency import ParamsDependency
@@ -39,6 +38,7 @@ from dvc.repo.experiments.utils import (
)
from dvc.ui import ui
from dvc.utils.objects import cached_property
from dvc.utils.studio import config_to_env
from .utils import get_remote_executor_refs
@@ -46,6 +46,7 @@ if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.repo.experiments import Experiments
from dvc.repo.experiments.executor.base import ExecutorResult
from dvc.repo.experiments.serialize import ExpRange
from dvc.scm import Git
logger = logging.getLogger(__name__)
@@ -253,7 +254,7 @@ class BaseStashQueue(ABC):
@abstractmethod
def reproduce(
self, copy_paths: Optional[List[str]] = None
self, copy_paths: Optional[List[str]] = None, message: Optional[str] = None
) -> Mapping[str, Mapping[str, str]]:
"""Reproduce queued experiments sequentially."""
@@ -419,12 +420,13 @@ 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
# save studio config to read later by dvc and dvclive
studio_config = get_studio_config(
dvc_studio_config=self.repo.config.get("studio")
)
run_env = {**config_to_env(studio_config), **run_env}
self._pack_args(*args, run_env=run_env, **kwargs)
# save experiment as a stash commit
msg = self._stash_msg(
stash_head,
@@ -520,7 +522,7 @@ class BaseStashQueue(ABC):
else:
extra = None
BaseExecutor.pack_repro_args(self.args_file, *args, extra=extra, **kwargs)
self.scm.add(self.args_file)
self.scm.add(self.args_file, force=True)
@staticmethod
def _format_new_params_msg(new_params, config_path):
@@ -728,9 +730,78 @@ class BaseStashQueue(ABC):
)
@abstractmethod
def get_running_exps(self, fetch_refs: bool = True) -> Dict[str, Dict]:
"""Get the execution info of the currently running experiments
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False,
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect data for active (running) experiments.
Args:
fetch_ref (bool): fetch completed checkpoints or not.
baseline_revs: Optional resolved baseline Git SHAs. If set, only experiments
derived from the specified revisions will be collected. Defaults to
collecting all experiments.
fetch_refs: Whether or not to fetch completed checkpoint commits from Git
remote.
Returns:
Dict mapping baseline revision to list of active experiments.
"""
@abstractmethod
def collect_queued_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect data for queued experiments.
Args:
baseline_revs: Optional resolved baseline Git SHAs. If set, only experiments
derived from the specified revisions will be collected. Defaults to
collecting all experiments.
Returns:
Dict mapping baseline revision to list of queued experiments.
"""
@abstractmethod
def collect_failed_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
"""Collect data for failed experiments.
Args:
baseline_revs: Optional resolved baseline Git SHAs. If set, only experiments
derived from the specified revisions will be collected. Defaults to
collecting all experiments.
Returns:
Dict mapping baseline revision to list of queued experiments.
"""
def active_repo(self, name: str) -> "Repo":
"""Return a Repo for the specified active experiment if it exists."""
from dvc.repo import Repo
from dvc.repo.experiments.exceptions import (
ExpNotStartedError,
InvalidExpRevError,
)
from dvc.repo.experiments.executor.base import ExecutorInfo, TaskStatus
for entry in self.iter_active():
if entry.name != name:
continue
infofile = self.get_infofile_path(entry.stash_rev)
executor_info = ExecutorInfo.load_json(infofile)
if executor_info.status < TaskStatus.RUNNING:
raise ExpNotStartedError(name)
dvc_root = os.path.join(executor_info.root_dir, executor_info.dvc_dir)
try:
return Repo(dvc_root)
except (FileNotFoundError, DvcException) as exc:
raise InvalidExpRevError(name) from exc
raise InvalidExpRevError(name)
+144 -19
View File
@@ -2,6 +2,7 @@ import hashlib
import locale
import logging
import os
from collections import defaultdict
from typing import (
TYPE_CHECKING,
Collection,
@@ -40,6 +41,7 @@ if TYPE_CHECKING:
from dvc.repo.experiments.executor.base import ExecutorResult
from dvc.repo.experiments.refs import ExpRefInfo
from dvc.repo.experiments.serialize import ExpRange
from dvc_task.app import FSApp
from dvc_task.proc.manager import ProcessManager
from dvc_task.worker import TemporaryWorker
@@ -123,6 +125,7 @@ class LocalCeleryQueue(BaseStashQueue):
without_mingle=True,
without_gossip=True,
timeout=10,
loglevel="debug" if logger.getEffectiveLevel() <= logging.DEBUG else "info",
)
def _spawn_worker(self, num: int = 1):
@@ -141,6 +144,8 @@ class LocalCeleryQueue(BaseStashQueue):
if num == 1:
# automatically run celery cleanup when primary worker shuts down
cmd.append("--clean")
if logger.getEffectiveLevel() <= logging.DEBUG:
cmd.append("-v")
name = f"dvc-exp-worker-{num}"
logger.debug("start a new worker: %s, node: %s", name, node_name)
@@ -175,12 +180,18 @@ class LocalCeleryQueue(BaseStashQueue):
return started
def put(
self, *args, copy_paths: Optional[List[str]] = None, **kwargs
self,
*args,
copy_paths: Optional[List[str]] = None,
message: Optional[str] = None,
**kwargs,
) -> QueueEntry:
"""Stash an experiment and add it to the queue."""
with get_exp_rwlock(self.repo, writes=["workspace", CELERY_STASH]):
entry = self._stash_exp(*args, **kwargs)
self.celery.signature(run_exp.s(entry.asdict(), copy_paths=copy_paths)).delay()
self.celery.signature(
run_exp.s(entry.asdict(), copy_paths=copy_paths, message=message)
).delay()
return entry
# NOTE: Queue consumption should not be done directly. Celery worker(s)
@@ -198,7 +209,9 @@ class LocalCeleryQueue(BaseStashQueue):
continue
args, kwargs, _embed = msg.decode()
entry_dict = kwargs.get("entry_dict", args[0])
logger.debug("Found queued task %s", entry_dict["stash_rev"])
logger.trace( # type: ignore[attr-defined]
"Found queued task %s", entry_dict["stash_rev"]
)
yield _MessageEntry(msg, QueueEntry.from_dict(entry_dict))
def _iter_processed(self) -> Generator[_MessageEntry, None, None]:
@@ -214,7 +227,9 @@ class LocalCeleryQueue(BaseStashQueue):
task_id = msg.headers["id"]
result: AsyncResult = AsyncResult(task_id)
if not result.ready():
logger.debug("Found active task %s", entry.stash_rev)
logger.trace( # type: ignore[attr-defined]
"Found active task %s", entry.stash_rev
)
yield _TaskEntry(result, entry)
def _iter_done_tasks(self) -> Generator[_TaskEntry, None, None]:
@@ -222,7 +237,9 @@ class LocalCeleryQueue(BaseStashQueue):
task_id = msg.headers["id"]
result: AsyncResult = AsyncResult(task_id)
if result.ready():
logger.debug("Found done task %s", entry.stash_rev)
logger.trace( # type: ignore[attr-defined]
"Found done task %s", entry.stash_rev
)
yield _TaskEntry(result, entry)
def iter_active(self) -> Generator[QueueEntry, None, None]:
@@ -253,7 +270,7 @@ class LocalCeleryQueue(BaseStashQueue):
yield QueueDoneResult(queue_entry, exp_result)
def reproduce(
self, copy_paths: Optional[List[str]] = None
self, copy_paths: Optional[List[str]] = None, message: Optional[str] = None
) -> Mapping[str, Mapping[str, str]]:
raise NotImplementedError
@@ -472,19 +489,6 @@ class LocalCeleryQueue(BaseStashQueue):
return celery_remove(self, *args, **kwargs)
def get_running_exps(self, fetch_refs: bool = True) -> Dict[str, Dict]:
"""Get the execution info of the currently running experiments
Args:
fetch_ref (bool): fetch completed checkpoints or not.
"""
result: Dict[str, Dict] = {}
for entry in self.iter_active():
result.update(
fetch_running_exp_from_temp_dir(self, entry.stash_rev, fetch_refs)
)
return result
def get_ref_and_entry_by_names(
self,
exp_names: Union[str, List[str]],
@@ -512,3 +516,124 @@ class LocalCeleryQueue(BaseStashQueue):
queue_entry = None if git_remote else queue_entry_match[exp_name]
results[exp_name] = ExpRefAndQueueEntry(exp_ref, queue_entry)
return results
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False,
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo import Repo
from dvc.repo.experiments.collect import collect_exec_branch
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry in self.iter_active():
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
if fetch_refs:
fetch_running_exp_from_temp_dir(self, entry.stash_rev, fetch_refs)
proc_info = self.proc.get(entry.stash_rev)
executor_info = self._load_info(entry.stash_rev)
if proc_info:
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
root=executor_info.root_dir,
log=proc_info.stdout,
pid=proc_info.pid,
task_id=entry.stash_rev,
)
else:
local_exec = None
dvc_root = os.path.join(executor_info.root_dir, executor_info.dvc_dir)
with Repo(dvc_root) as exec_repo:
kwargs["cache"] = self.repo.experiments.cache
exps = list(
collect_exec_branch(exec_repo, executor_info.baseline_rev, **kwargs)
)
exps[0].rev = entry.stash_rev
exps[0].name = entry.name
result[entry.baseline_rev].append(
ExpRange(
exps,
executor=ExpExecutor(
"running",
name=executor_info.location,
local=local_exec,
),
name=entry.name,
)
)
return result
def collect_queued_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo.experiments.collect import collect_rev
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry in self.iter_queued():
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
exp = collect_rev(self.repo, entry.stash_rev, **kwargs)
exp.name = entry.name
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
task_id=entry.stash_rev,
)
result[entry.baseline_rev].append(
ExpRange(
[exp],
executor=ExpExecutor("queued", name="dvc-task", local=local_exec),
name=entry.name,
)
)
return result
def collect_failed_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo.experiments.collect import collect_rev
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
SerializableError,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry, _ in self.iter_failed():
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
proc_info = self.proc.get(entry.stash_rev)
if proc_info:
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
log=proc_info.stdout,
pid=proc_info.pid,
returncode=proc_info.returncode,
task_id=entry.stash_rev,
)
else:
local_exec = None
exp = collect_rev(self.repo, entry.stash_rev, **kwargs)
exp.name = entry.name
exp.error = SerializableError("Experiment run failed")
result[entry.baseline_rev].append(
ExpRange(
[exp],
executor=ExpExecutor("failed", local=local_exec),
name=entry.name,
)
)
return result
+7 -1
View File
@@ -91,7 +91,11 @@ def cleanup_exp(executor: TempDirExecutor, infofile: str) -> None:
@shared_task
def run_exp(entry_dict: Dict[str, Any], copy_paths: Optional[List[str]] = None) -> None:
def run_exp(
entry_dict: Dict[str, Any],
copy_paths: Optional[List[str]] = None,
message: Optional[str] = None,
) -> None:
"""Run a full experiment.
Experiment subtasks are executed inline as one atomic operation.
@@ -111,6 +115,8 @@ def run_exp(entry_dict: Dict[str, Any], copy_paths: Optional[List[str]] = None)
if copy_paths:
for path in copy_paths:
cmd.extend(["--copy-paths", path])
if message:
cmd.extend(["--message", message])
proc_dict = queue.proc.run_signature(cmd, name=entry.stash_rev)()
collect_exp.s(proc_dict, entry_dict)()
finally:
+50 -4
View File
@@ -1,7 +1,7 @@
import logging
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, Generator, List, Optional
from typing import TYPE_CHECKING, Collection, Dict, Generator, List, Optional
from funcy import first
@@ -19,6 +19,7 @@ from .workspace import WorkspaceQueue
if TYPE_CHECKING:
from dvc.repo.experiments import Experiments
from dvc.repo.experiments.executor.base import BaseExecutor, ExecutorResult
from dvc.repo.experiments.serialize import ExpRange
from dvc_task.proc.manager import ProcessManager
logger = logging.getLogger(__name__)
@@ -96,6 +97,7 @@ class TempDirQueue(WorkspaceQueue):
entry: QueueEntry,
executor: "BaseExecutor",
copy_paths: Optional[List[str]] = None,
message: Optional[str] = None,
**kwargs,
) -> Dict[str, Dict[str, str]]:
from dvc.stage.monitor import CheckpointKilledError
@@ -112,6 +114,7 @@ class TempDirQueue(WorkspaceQueue):
log_level=logger.getEffectiveLevel(),
log_errors=True,
copy_paths=copy_paths,
message=message,
)
if not exec_result.exp_hash:
raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'")
@@ -141,10 +144,53 @@ class TempDirQueue(WorkspaceQueue):
) -> Dict[str, str]:
return BaseStashQueue.collect_executor(exp, executor, exec_result)
def get_running_exps(self, fetch_refs: bool = True) -> Dict[str, Dict]:
result: Dict[str, Dict] = {}
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False,
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo import Repo
from dvc.repo.experiments.collect import collect_exec_branch
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
for entry in self.iter_active():
result.update(
if baseline_revs and entry.baseline_rev not in baseline_revs:
continue
if fetch_refs:
fetch_running_exp_from_temp_dir(self, entry.stash_rev, fetch_refs)
proc_info = self.proc.get(entry.stash_rev)
infofile = self.get_infofile_path(entry.stash_rev)
executor_info = ExecutorInfo.load_json(infofile)
if proc_info:
local_exec: Optional[LocalExpExecutor] = LocalExpExecutor(
root=executor_info.root_dir,
log=proc_info.stdout,
pid=proc_info.pid,
)
else:
local_exec = None
dvc_root = os.path.join(executor_info.root_dir, executor_info.dvc_dir)
with Repo(dvc_root) as repo:
exps = list(
collect_exec_branch(repo, executor_info.baseline_rev, **kwargs)
)
exps[0].rev = entry.stash_rev
exps[0].name = entry.name
result[entry.baseline_rev].append(
ExpRange(
exps,
executor=ExpExecutor(
"running",
name=executor_info.location,
local=local_exec,
),
name=entry.name,
)
)
return result
+92 -80
View File
@@ -1,28 +1,27 @@
import json
import logging
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Collection, Dict, Generator, List, Optional
import psutil
from funcy import first
from voluptuous import Invalid
from dvc.exceptions import DvcException
from dvc.lock import make_lock
from dvc.repo.experiments.exceptions import ExpQueueEmptyError
from dvc.repo.experiments.executor.base import ExecutorInfo, TaskStatus
from dvc.repo.experiments.executor.local import WorkspaceExecutor
from dvc.repo.experiments.refs import EXEC_BRANCH, WORKSPACE_STASH
from dvc.repo.experiments.utils import get_exp_rwlock
from dvc.rwlock import RWLOCK_FILE, RWLOCK_LOCK, SCHEMA
from dvc.utils import relpath
from dvc.utils.fs import remove
from dvc.utils.serialize import load_json
from .base import BaseStashQueue, QueueEntry, QueueGetResult
if TYPE_CHECKING:
from dvc.repo.experiments import Experiments
from dvc.repo.experiments.executor.base import BaseExecutor, ExecutorResult
from dvc.repo.experiments.serialize import ExpRange
from .base import QueueDoneResult
@@ -83,14 +82,16 @@ class WorkspaceQueue(BaseStashQueue):
raise NotImplementedError
def reproduce(
self, copy_paths: Optional[List[str]] = None
self, copy_paths: Optional[List[str]] = None, message: Optional[str] = None
) -> Dict[str, Dict[str, str]]:
results: Dict[str, Dict[str, str]] = defaultdict(dict)
try:
while True:
entry, executor = self.get()
results.update(
self._reproduce_entry(entry, executor, copy_paths=copy_paths)
self._reproduce_entry(
entry, executor, copy_paths=copy_paths, message=message
)
)
except ExpQueueEmptyError:
pass
@@ -101,9 +102,14 @@ class WorkspaceQueue(BaseStashQueue):
) -> Dict[str, Dict[str, str]]:
kwargs.pop("copy_paths", None)
from dvc.stage.monitor import CheckpointKilledError
from dvc_task.proc.process import ProcessInfo
results: Dict[str, Dict[str, str]] = defaultdict(dict)
exec_name = self._EXEC_NAME or entry.stash_rev
proc_info = ProcessInfo(os.getpid(), None, None, None, None)
proc_info_path = self._proc_info_path(exec_name)
os.makedirs(os.path.dirname(proc_info_path), exist_ok=True)
proc_info.dump(proc_info_path)
infofile = self.get_infofile_path(exec_name)
try:
rev = entry.stash_rev
@@ -113,6 +119,7 @@ class WorkspaceQueue(BaseStashQueue):
infofile=infofile,
log_level=logger.getEffectiveLevel(),
log_errors=not isinstance(executor, WorkspaceExecutor),
message=kwargs.get("message"),
)
if not exec_result.exp_hash:
raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'")
@@ -129,8 +136,29 @@ class WorkspaceQueue(BaseStashQueue):
raise DvcException(f"Failed to reproduce experiment '{rev[:7]}'") from exc
finally:
executor.cleanup(infofile)
remove(self._proc_info_path(exec_name))
return results
def _proc_info_path(self, name: str) -> str:
return os.path.join(self.pid_dir, name, f"{name}.json")
@property
def _active_pid(self) -> Optional[int]:
from dvc_task.proc.process import ProcessInfo
assert self._EXEC_NAME
name = self._EXEC_NAME
try:
proc_info = ProcessInfo.load(self._proc_info_path(name))
pid = proc_info.pid
if psutil.pid_exists(pid):
return pid
logger.debug("Workspace exec PID '%d' no longer exists, removing.", pid)
remove(self._proc_info_path(name))
except (FileNotFoundError, json.JSONDecodeError):
pass
return None
@staticmethod
def collect_executor( # pylint: disable=unused-argument
exp: "Experiments",
@@ -163,87 +191,71 @@ class WorkspaceQueue(BaseStashQueue):
):
raise NotImplementedError
def check_rwlock(
self,
hardlink: bool = False,
autocorrect: bool = False,
) -> bool:
"""Check and autocorrect the RWLock status for file paths.
Args:
hardlink (bool): use hardlink lock to guard rwlock file when on
edit.
autocorrect (bool): autocorrect corrupted rwlock file.
Return:
(bool): if the pid alive.
"""
path = self.repo.fs.path.join(self.repo.tmp_dir, RWLOCK_FILE)
rwlock_guard = make_lock(
self.repo.fs.path.join(self.repo.tmp_dir, RWLOCK_LOCK),
tmp_dir=self.repo.tmp_dir,
hardlink_lock=hardlink,
)
with rwlock_guard:
try:
with self.repo.fs.open(path, encoding="utf-8") as fobj:
lock: Dict[str, List[Dict]] = SCHEMA(json.load(fobj))
file_path = first(lock["read"])
if not file_path:
return False
lock_info = first(lock["read"][file_path])
pid = int(lock_info["pid"])
if psutil.pid_exists(pid):
return True
cmd = lock_info["cmd"]
logger.warning(
"Process '%s' with (Pid %s), in RWLock-file '%s' had been killed.",
cmd,
pid,
relpath(path),
)
except FileNotFoundError:
return False
except json.JSONDecodeError:
logger.warning(
"Unable to read RWLock-file '%s'. JSON structure is corrupted",
relpath(path),
)
except Invalid:
logger.warning("RWLock-file '%s' format error.", relpath(path))
if autocorrect:
logger.warning("Delete corrupted RWLock-file '%s'", relpath(path))
remove(path)
return False
def get_running_exps(
self,
fetch_refs: bool = True, # noqa: ARG002
) -> Dict[str, Dict]:
from dvc.utils.serialize import load_json
def get_running_exp(self) -> Optional[str]:
"""Return the name of the exp running in workspace (if it exists)."""
assert self._EXEC_NAME
result: Dict[str, Dict] = {}
if not self.check_rwlock(autocorrect=True):
return result
if self._active_pid is None:
return None
infofile = self.get_infofile_path(self._EXEC_NAME)
try:
info = ExecutorInfo.from_dict(load_json(infofile))
except OSError:
return None
return info.name
def collect_active_data(
self,
baseline_revs: Optional[Collection[str]],
fetch_refs: bool = False, # noqa: ARG002
**kwargs,
) -> Dict[str, List["ExpRange"]]:
from dvc.repo.experiments.collect import collect_exec_branch
from dvc.repo.experiments.serialize import (
ExpExecutor,
ExpRange,
LocalExpExecutor,
)
result: Dict[str, List[ExpRange]] = defaultdict(list)
pid = self._active_pid
if pid is None:
return result
assert self._EXEC_NAME
infofile = self.get_infofile_path(self._EXEC_NAME)
try:
info = ExecutorInfo.from_dict(load_json(infofile))
except OSError:
return result
if info.status < TaskStatus.FAILED:
# If we are appending to a checkpoint branch in a workspace
# run, show the latest checkpoint as running.
if info.status == TaskStatus.SUCCESS:
return result
last_rev = self.scm.get_ref(EXEC_BRANCH)
if last_rev:
result[last_rev] = info.asdict()
else:
result[self._EXEC_NAME] = info.asdict()
if (
(not baseline_revs or info.baseline_rev in baseline_revs)
and info.status < TaskStatus.FAILED
and info.status != TaskStatus.SUCCESS
):
local_exec = LocalExpExecutor(root=info.root_dir, pid=pid)
exps = list(collect_exec_branch(self.repo, info.baseline_rev, **kwargs))
exps[0].name = info.name
result[info.baseline_rev] = [
ExpRange(
exps,
executor=ExpExecutor("running", name="workspace", local=local_exec),
name=info.name,
)
]
return result
def collect_queued_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
raise NotImplementedError
def collect_failed_data(
self,
baseline_revs: Optional[Collection[str]],
**kwargs,
) -> Dict[str, List["ExpRange"]]:
raise NotImplementedError
+10 -4
View File
@@ -10,8 +10,9 @@ WORKSPACE_STASH = EXPS_STASH
APPLY_NAMESPACE = f"{EXPS_NAMESPACE}/apply"
APPLY_HEAD = f"{APPLY_NAMESPACE}/ORIG_HEAD"
APPLY_STASH = f"{APPLY_NAMESPACE}/stash"
CELERY_STASH = f"{EXPS_NAMESPACE}/celery/stash"
CELERY_FAILED_STASH = f"{EXPS_NAMESPACE}/celery/failed"
CELERY_NAMESPACE = f"{EXPS_NAMESPACE}/celery"
CELERY_STASH = f"{CELERY_NAMESPACE}/stash"
CELERY_FAILED_STASH = f"{CELERY_NAMESPACE}/failed"
EXEC_NAMESPACE = f"{EXPS_NAMESPACE}/exec"
EXEC_APPLY = f"{EXEC_NAMESPACE}/EXEC_APPLY"
EXEC_CHECKPOINT = f"{EXEC_NAMESPACE}/EXEC_CHECKPOINT"
@@ -19,9 +20,14 @@ EXEC_BRANCH = f"{EXEC_NAMESPACE}/EXEC_BRANCH"
EXEC_BASELINE = f"{EXEC_NAMESPACE}/EXEC_BASELINE"
EXEC_HEAD = f"{EXEC_NAMESPACE}/EXEC_HEAD"
EXEC_MERGE = f"{EXEC_NAMESPACE}/EXEC_MERGE"
EXPS_TEMP = f"{EXPS_NAMESPACE}/temp"
TEMP_NAMESPACE = f"{EXPS_NAMESPACE}/temp"
STASHES = {WORKSPACE_STASH, CELERY_STASH}
ITER_SKIP_NAMESPACES = {APPLY_NAMESPACE, EXEC_NAMESPACE}
ITER_SKIP_NAMESPACES = {
APPLY_NAMESPACE,
CELERY_NAMESPACE,
EXEC_NAMESPACE,
TEMP_NAMESPACE,
}
class ExpRefInfo:
+5 -3
View File
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
def remove( # noqa: C901, PLR0912
repo: "Repo",
exp_names: Union[None, str, List[str]] = None,
rev: Optional[str] = None,
rev: Optional[Union[List[str], str]] = None,
all_commits: bool = False,
num: int = 1,
queue: bool = False,
@@ -60,6 +60,8 @@ def remove( # noqa: C901, PLR0912
if remained:
raise UnresolvedExpNamesError(remained, git_remote=git_remote)
elif rev:
if isinstance(rev, str):
rev = [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())
@@ -85,14 +87,14 @@ def remove( # noqa: C901, PLR0912
def _resolve_exp_by_baseline(
repo: "Repo",
rev: str,
rev: List[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_dict = iter_revs(repo.scm, rev, num)
rev_set = set(rev_dict.keys())
ref_info_dict = exp_refs_by_baseline(repo.scm, rev_set, git_remote)
for _, ref_info_list in ref_info_dict.items():
+3
View File
@@ -20,6 +20,7 @@ def run( # noqa: C901, PLR0912
tmp_dir: bool = False,
queue: bool = False,
copy_paths: Optional[Iterable[str]] = None,
message: Optional[str] = None,
**kwargs,
) -> Dict[str, str]:
"""Reproduce the specified targets as an experiment.
@@ -74,6 +75,7 @@ def run( # noqa: C901, PLR0912
params=path_overrides,
tmp_dir=tmp_dir,
copy_paths=copy_paths,
message=message,
**kwargs,
)
@@ -96,6 +98,7 @@ def run( # noqa: C901, PLR0912
targets=targets,
params=sweep_overrides,
copy_paths=copy_paths,
message=message,
**kwargs,
)
if sweep_overrides:
+5 -1
View File
@@ -16,6 +16,7 @@ def save(
name: Optional[str] = None,
force: bool = False,
include_untracked: Optional[List[str]] = None,
message: Optional[str] = None,
) -> Optional[str]:
"""Save the current workspace status as an experiment.
@@ -29,7 +30,10 @@ def save(
try:
save_result = executor.save(
executor.info, force=force, include_untracked=include_untracked
executor.info,
force=force,
include_untracked=include_untracked,
message=message,
)
result = queue.collect_executor(repo.experiments, executor, save_result)
finally:
+81 -26
View File
@@ -1,7 +1,7 @@
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Literal, Optional
from dvc.exceptions import DvcException
from dvc.repo.metrics.show import _gather_metrics
@@ -25,17 +25,15 @@ class _ISOEncoder(json.JSONEncoder):
@dataclass(frozen=True)
class SerializableExp:
"""Serializable experiment state."""
"""Serializable experiment data."""
rev: str
timestamp: Optional[datetime] = None
params: Dict[str, Any] = field(default_factory=dict)
metrics: Dict[str, Any] = field(default_factory=dict)
deps: Dict[str, "_ExpDep"] = field(default_factory=dict)
outs: Dict[str, "_ExpOut"] = field(default_factory=dict)
status: Optional[str] = None
executor: Optional[str] = None
error: Optional["SerializableError"] = None
deps: Dict[str, "ExpDep"] = field(default_factory=dict)
outs: Dict[str, "ExpOut"] = field(default_factory=dict)
meta: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_repo(
@@ -43,6 +41,7 @@ class SerializableExp:
repo: "Repo",
rev: Optional[str] = None,
onerror: Optional[Callable] = None,
param_deps: bool = False,
**kwargs,
) -> "SerializableExp":
"""Returns a SerializableExp from the current repo state.
@@ -57,29 +56,26 @@ class SerializableExp:
rev = rev or repo.get_rev()
assert rev
status: Optional[str] = kwargs.get("status")
# NOTE: _gather_params/_gather_metrics return defaultdict which is not
# supported in dataclasses.asdict() on all python releases
# see https://bugs.python.org/issue35540
params = dict(_gather_params(repo, onerror=onerror))
if status and status.lower() in ("queued", "failed"):
metrics: Dict[str, Any] = {}
else:
metrics = dict(
_gather_metrics(
repo,
targets=None,
rev=rev[:7],
recursive=False,
onerror=onerror_collect,
)
params = dict(_gather_params(repo, deps=param_deps, onerror=onerror))
metrics = dict(
_gather_metrics(
repo,
targets=None,
rev=rev[:7],
recursive=False,
onerror=onerror_collect,
)
)
meta = cls._gather_meta(repo)
return cls(
rev=rev,
params=params,
metrics=metrics,
deps={
relpath(dep.fs_path, repo.root_dir): _ExpDep(
relpath(dep.fs_path, repo.root_dir): ExpDep(
hash=dep.hash_info.value if dep.hash_info else None,
size=dep.meta.size if dep.meta else None,
nfiles=dep.meta.nfiles if dep.meta else None,
@@ -88,7 +84,7 @@ class SerializableExp:
if not isinstance(dep, (ParamsDependency, RepoDependency))
},
outs={
relpath(out.fs_path, repo.root_dir): _ExpOut(
relpath(out.fs_path, repo.root_dir): ExpOut(
hash=out.hash_info.value if out.hash_info else None,
size=out.meta.size if out.meta else None,
nfiles=out.meta.nfiles if out.meta else None,
@@ -98,9 +94,16 @@ class SerializableExp:
for out in repo.index.outs
if not (out.is_metric or out.is_plot)
},
meta=meta,
**kwargs,
)
@staticmethod
def _gather_meta(repo: "Repo") -> Dict[str, Any]:
return {
"has_checkpoints": any(stage.is_checkpoint for stage in repo.index.stages)
}
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
@@ -113,6 +116,10 @@ class SerializableExp:
parsed = json.loads(data)
if "timestamp" in parsed:
parsed["timestamp"] = datetime.fromisoformat(parsed["timestamp"])
if "deps" in parsed:
parsed["deps"] = {k: ExpDep(**v) for k, v in parsed["deps"].items()}
if "outs" in parsed:
parsed["outs"] = {k: ExpOut(**v) for k, v in parsed["outs"].items()}
return cls(**parsed)
except (TypeError, json.JSONDecodeError) as exc:
raise DeserializeError("failed to load SerializableExp") from exc
@@ -120,8 +127,7 @@ class SerializableExp:
@property
def contains_error(self) -> bool:
return (
self.error is not None
or self.params.get("error")
self.params.get("error")
or any(value.get("error") for value in self.params.values())
or self.metrics.get("error")
or any(value.get("error") for value in self.metrics.values())
@@ -129,14 +135,14 @@ class SerializableExp:
@dataclass(frozen=True)
class _ExpDep:
class ExpDep:
hash: Optional[str] # noqa: A003
size: Optional[int]
nfiles: Optional[int]
@dataclass(frozen=True)
class _ExpOut:
class ExpOut:
hash: Optional[str] # noqa: A003
size: Optional[int]
nfiles: Optional[int]
@@ -162,3 +168,52 @@ class SerializableError:
return cls(**parsed)
except (TypeError, json.JSONDecodeError) as exc:
raise DeserializeError("failed to load SerializableError") from exc
@dataclass
class ExpState:
"""Git/DVC experiment state."""
rev: str
name: Optional[str] = None
data: Optional[SerializableExp] = None
error: Optional[SerializableError] = None
experiments: Optional[List["ExpRange"]] = None
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class ExpRange:
revs: List["ExpState"]
executor: Optional["ExpExecutor"] = None
name: Optional[str] = None
def __len__(self) -> int:
return len(self.revs)
def __iter__(self) -> Iterator["ExpState"]:
return iter(self.revs)
def __getitem__(self, index: int) -> "ExpState":
return self.revs[index]
def dumpd(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class LocalExpExecutor:
root: Optional[str] = None
log: Optional[str] = None
pid: Optional[int] = None
returncode: Optional[int] = None
task_id: Optional[str] = None
@dataclass
class ExpExecutor:
state: Literal["success", "queued", "running", "failed"]
name: Optional[str] = None
local: Optional[LocalExpExecutor] = None
+352 -479
View File
@@ -1,517 +1,390 @@
import logging
from collections import OrderedDict, defaultdict
from datetime import datetime
from enum import Enum
from itertools import chain
from collections import Counter, defaultdict
from datetime import date, datetime
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Literal,
Mapping,
NamedTuple,
Optional,
Set,
Tuple,
Union,
)
from scmrepo.exceptions import SCMError as InnerScmError
from dvc.exceptions import InvalidArgumentError
from dvc.scm import Git
from dvc.ui import ui
from dvc.utils.flatten import flatten
from dvc.exceptions import DvcException
from dvc.scm import Git, SCMError, iter_revs, resolve_rev
from .refs import ExpRefInfo
from .serialize import SerializableError, SerializableExp
from .collect import collect
if TYPE_CHECKING:
from scmrepo.git.objects import GitCommit
from dvc.compare import TabularData
from dvc.repo import Repo
from dvc.ui.table import CellT
from .serialize import ExpRange, ExpState
logger = logging.getLogger(__name__)
class ExpStatus(Enum):
Success = 0
Queued = 1
Running = 2
Failed = 3
class _CachedError(DvcException):
def __init__(self, msg, typ, *args):
super().__init__(msg, *args)
self.typ = typ
def _is_scm_error(collected_exp: Dict[str, Any]) -> bool:
if "error" in collected_exp and (
isinstance(collected_exp["error"], (_CachedError, SCMError, InnerScmError))
):
return True
return False
def _format_exp(exp: SerializableExp) -> Dict[str, Any]:
# SerializableExp always includes error but we need to strip it from show
# output when it is false-y to maintain compatibility w/tools that consume
# json output and assume that "error" key presence means there was an error
exp_dict = exp.dumpd()
if "error" in exp_dict and not exp_dict["error"]:
del exp_dict["error"]
return {"data": exp_dict}
def _format_error(error: SerializableError):
msg = error.msg or "None"
return {"data": {}, "error": _CachedError(msg, error.type)}
def collect_experiment_commit(
def show(
repo: "Repo",
exp_rev: str,
status: ExpStatus = ExpStatus.Success,
param_deps: bool = False,
force: bool = False,
revs: Union[List[str], str, None] = None,
all_branches: bool = False,
all_tags: bool = False,
all_commits: bool = False,
num: int = 1,
hide_queued: bool = False,
hide_failed: bool = False,
sha_only: bool = False,
**kwargs,
) -> Dict[str, Any]:
cache = repo.experiments.cache
# TODO: support filtering serialized exp when param_deps is set
if exp_rev != "workspace" and not (force or param_deps):
cached_exp = cache.get(exp_rev)
if cached_exp:
if status == ExpStatus.Running or (
isinstance(cached_exp, SerializableExp)
and cached_exp.status == ExpStatus.Running.name
):
# expire cached queued exp entry once we start running it
cache.delete(exp_rev)
elif isinstance(cached_exp, SerializableError):
return _format_error(cached_exp)
else:
return _format_exp(cached_exp)
try:
exp = _collect_from_repo(
repo,
exp_rev,
status=status,
param_deps=param_deps,
force=force,
**kwargs,
)
if not (
exp_rev == "workspace"
or status == ExpStatus.Running
or param_deps
or exp.contains_error
):
cache.put(exp, force=True)
return _format_exp(exp)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
logger.debug("", exc_info=True)
error = SerializableError(str(exc), type(exc).__name__)
return _format_error(error)
def _collect_from_repo(
repo: "Repo",
exp_rev: str,
status: ExpStatus = ExpStatus.Success,
running: Optional[Dict[str, Any]] = None,
**kwargs,
) -> "SerializableExp":
running = running or {}
with repo.switch(exp_rev) as rev:
if rev == "workspace":
timestamp: Optional[datetime] = None
else:
commit = repo.scm.resolve_commit(rev)
timestamp = datetime.fromtimestamp(commit.commit_time)
if status == ExpStatus.Running:
executor: Optional[str] = running.get(exp_rev, {}).get("location", None)
else:
executor = None
if status == ExpStatus.Failed:
error: Optional["SerializableError"] = SerializableError(
"Experiment run failed."
)
else:
error = None
return SerializableExp.from_repo(
repo,
rev=exp_rev,
timestamp=timestamp,
status=status.name,
executor=executor,
error=error,
)
def _collect_complete_experiment(
repo: "Repo",
baseline: str,
exp_rev: str,
running: Dict[str, Any],
revs: List[str],
**kwargs,
) -> Dict[str, Dict[str, Any]]:
results: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
checkpoint = len(revs) > 1
prev = ""
for rev in revs:
status = ExpStatus.Running if rev in running else ExpStatus.Success
collected_exp = collect_experiment_commit(
repo,
rev,
status=status,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
return {}
if checkpoint:
exp = {"checkpoint_tip": exp_rev}
if prev:
results[prev]["data"][ # type: ignore[unreachable]
"checkpoint_parent"
] = rev
if rev in results:
results[rev]["data"].update(exp)
results.move_to_end(rev)
else:
exp.update(collected_exp["data"])
else:
exp = collected_exp["data"]
if rev not in results:
results[rev] = {"data": exp}
prev = rev
if checkpoint and prev:
results[prev]["data"]["checkpoint_parent"] = baseline
return results
def _collect_branch(
repo: "Repo",
baseline: str,
running: Dict[str, Any],
refs: Optional[Iterable[str]] = None,
**kwargs,
) -> Dict[str, Dict[str, Any]]:
results: Dict[str, Dict[str, Any]] = OrderedDict()
status = ExpStatus.Running if baseline in running else ExpStatus.Success
results["baseline"] = collect_experiment_commit(
) -> List["ExpState"]:
return collect(
repo,
baseline,
status=status,
running=running,
revs=revs,
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
num=num,
hide_queued=hide_queued,
hide_failed=hide_failed,
sha_only=sha_only,
**kwargs,
)
if baseline == "workspace" or _is_scm_error(results["baseline"]):
return results
ref_info = ExpRefInfo(baseline_sha=baseline)
commits: List[Tuple[str, "GitCommit", str, List[str]]] = []
assert isinstance(repo.scm, Git)
if refs:
ref_it = (ref for ref in iter(refs) if ref.startswith(str(ref_info)))
else:
ref_it = repo.scm.iter_refs(base=str(ref_info))
for ref in ref_it:
try:
commit = repo.scm.resolve_commit(ref)
exp_rev = resolve_rev(repo.scm, ref)
revs = list(repo.scm.branch_revs(exp_rev, baseline))
except (SCMError, InnerScmError):
continue
commits.append((ref, commit, exp_rev, revs))
def tabulate(
baseline_states: Iterable["ExpState"],
fill_value: Optional[str] = "-",
error_value: str = "!",
**kwargs,
) -> Tuple["TabularData", Dict[str, Iterable[str]]]:
"""Return table data for experiments.
for exp_ref, _, exp_rev, revs in sorted(
commits, key=lambda x: x[1].commit_time, reverse=True
):
ref_info = ExpRefInfo.from_ref(exp_ref)
assert ref_info.baseline_sha == baseline
collected_exp = _collect_complete_experiment(
repo,
baseline=baseline,
exp_rev=exp_rev,
running=running,
revs=revs,
Returns:
Tuple of (table_data, data_headers)
"""
from funcy import lconcat
from funcy.seqs import flatten as flatten_list
from dvc.compare import TabularData
data_names = _collect_names(baseline_states)
metrics_names = data_names.metrics
params_names = data_names.params
deps_names = data_names.sorted_deps
headers = [
"Experiment",
"rev",
"typ",
"Created",
"parent",
"State",
"Executor",
]
names = {**metrics_names, **params_names}
counter = Counter(flatten_list([list(a.keys()) for a in names.values()]))
counter.update(headers)
metrics_headers = _normalize_headers(metrics_names, counter)
params_headers = _normalize_headers(params_names, counter)
all_headers = lconcat(headers, metrics_headers, params_headers, deps_names)
td = TabularData(all_headers, fill_value=fill_value)
td.extend(
_build_rows(
baseline_states,
all_headers=all_headers,
metrics_headers=metrics_headers,
params_headers=params_headers,
metrics_names=metrics_names,
params_names=params_names,
deps_names=deps_names,
fill_value=fill_value,
error_value=error_value,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
results.update(collected_exp)
return results
)
data_headers: Dict[str, Iterable[str]] = {
"metrics": metrics_headers,
"params": params_headers,
"deps": deps_names,
}
return td, data_headers
def get_branch_names(
scm: "Git", baseline_set: Iterable[str], refs: Optional[Iterable[str]] = None
) -> Dict[str, Optional[str]]:
names: Dict[str, Optional[str]] = {}
bases = [
f"refs/exps/{baseline[:2]}/{baseline[2:]}/" for baseline in baseline_set
] + ["refs/heads/", "refs/tags/"]
ref_it = iter(refs) if refs else scm.iter_refs()
for ref in ref_it:
for base in bases:
if ref.startswith(base):
try:
rev = scm.get_ref(ref)
names[rev] = ref[len(base) :]
except KeyError:
logger.debug("unresolved ref %s", ref)
break
logger.debug("found refs for revs: %s", names)
return names
def update_names( # noqa: C901
repo: "Repo",
branch_names: Dict[str, Optional[str]],
result: Dict[str, Dict[str, Any]],
):
rev_set = set()
for baseline in result:
for rev in result[baseline]:
if rev == "baseline":
rev = baseline
if rev != "workspace":
rev_set.add(rev)
if rev_set:
rev_set.difference_update(branch_names.keys())
exact_name = repo.experiments.get_exact_name(rev_set)
for baseline, baseline_results in result.items():
name_set: Set[str] = set()
for rev, rev_result in baseline_results.items():
name: Optional[str] = None
if rev == "baseline":
rev = baseline
if rev == "workspace":
continue
name = branch_names.get(rev, None) or exact_name[rev]
if name and name not in name_set:
name_set.add(name)
rev_result["data"]["name"] = name
def _collect_active_experiment(
repo: "Repo",
found_revs: Dict[str, List[str]],
running: Dict[str, Any],
def _build_rows(
baseline_states: Iterable["ExpState"],
*,
all_headers: Iterable[str],
fill_value: Optional[str],
sort_by: Optional[str] = None,
sort_order: Optional[Literal["asc", "desc"]] = None,
**kwargs,
) -> Dict[str, Dict[str, Any]]:
result: Dict[str, Dict] = defaultdict(OrderedDict)
for entry in chain(
repo.experiments.tempdir_queue.iter_active(),
repo.experiments.celery_queue.iter_active(),
):
stash_rev = entry.stash_rev
if entry.baseline_rev in found_revs and (
stash_rev not in running or not running[stash_rev].get("last")
) -> Iterator[Tuple["CellT", ...]]:
for baseline in baseline_states:
row: Dict[str, "CellT"] = {k: fill_value for k in all_headers}
row["Experiment"] = ""
if baseline.name:
row["rev"] = baseline.name
elif Git.is_sha(baseline.rev):
row["rev"] = baseline.rev[:7]
else:
row["rev"] = baseline.rev
row["typ"] = "baseline"
row["parent"] = ""
if baseline.data:
row["Created"] = format_time(
baseline.data.timestamp, fill_value=fill_value, **kwargs
)
row.update(
_data_cells( # pylint: disable=missing-kwoa
baseline, fill_value=fill_value, **kwargs
)
)
yield tuple(row.values())
if baseline.experiments:
if sort_by:
metrics_names: Mapping[str, Iterable[str]] = kwargs.get(
"metrics_names", {}
)
params_names: Mapping[str, Iterable[str]] = kwargs.get(
"params_names", {}
)
sort_path, sort_name, sort_type = _sort_column(
sort_by, metrics_names, params_names
)
reverse = sort_order == "desc"
experiments = _sort_exp(
baseline.experiments, sort_path, sort_name, sort_type, reverse
)
else:
experiments = baseline.experiments
for i, child in enumerate(experiments):
yield from _exp_range_rows(
child,
all_headers=all_headers,
fill_value=fill_value,
is_base=i == len(baseline.experiments) - 1,
**kwargs,
)
def _sort_column(
sort_by: str,
metric_names: Mapping[str, Iterable[str]],
param_names: Mapping[str, Iterable[str]],
) -> Tuple[str, str, str]:
path, _, sort_name = sort_by.rpartition(":")
matches: Set[Tuple[str, str, str]] = set()
if path:
if path in metric_names and sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
if path in param_names and sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
else:
for path in metric_names:
if sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
for path in param_names:
if sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
if len(matches) == 1:
return matches.pop()
if len(matches) > 1:
raise InvalidArgumentError(
"Ambiguous sort column '{}' matched '{}'".format(
sort_by,
", ".join([f"{path}:{name}" for path, name, _ in matches]),
)
)
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
def _sort_exp(
experiments: Iterable["ExpRange"],
sort_path: str,
sort_name: str,
typ: str,
reverse: bool,
) -> List["ExpRange"]:
from funcy import first
def _sort(exp_range: "ExpRange"):
exp = first(exp_range.revs)
if not exp:
return True
data = exp.data.dumpd().get(typ, {}).get(sort_path, {}).get("data", {})
val = flatten(data).get(sort_name)
return val is None, val
return sorted(experiments, key=_sort, reverse=reverse)
def _exp_range_rows(
exp_range: "ExpRange",
*,
all_headers: Iterable[str],
fill_value: Optional[str],
is_base: bool = False,
**kwargs,
) -> Iterator[Tuple["CellT", ...]]:
for i, exp in enumerate(exp_range.revs):
row: Dict[str, "CellT"] = {k: fill_value for k in all_headers}
row["Experiment"] = exp.name or ""
row["rev"] = exp.rev[:7] if Git.is_sha(exp.rev) else exp.rev
if len(exp_range.revs) > 1:
if i == 0:
row["typ"] = "checkpoint_tip"
elif i == len(exp_range.revs) - 1:
row["typ"] = "checkpoint_base"
else:
row["typ"] = "checkpoint_commit"
else:
row["typ"] = "branch_base" if is_base else "branch_commit"
row["parent"] = ""
if exp_range.executor:
row["State"] = exp_range.executor.state.capitalize()
if exp_range.executor.name:
row["Executor"] = exp_range.executor.name.capitalize()
if exp.data:
row["Created"] = format_time(
exp.data.timestamp, fill_value=fill_value, **kwargs
)
row.update(
_data_cells( # pylint: disable=missing-kwoa
exp, fill_value=fill_value, **kwargs
)
)
yield tuple(row.values())
def _data_cells(
exp: "ExpState",
*,
metrics_headers: Iterable[str],
params_headers: Iterable[str],
metrics_names: Mapping[str, Iterable[str]],
params_names: Mapping[str, Iterable[str]],
deps_names: Iterable[str],
fill_value: Optional[str] = "-",
error_value: str = "!",
precision: Optional[int] = None,
**kwargs,
) -> Iterator[Tuple[str, "CellT"]]:
def _d_cells(
d: Mapping[str, Any],
names: Mapping[str, Iterable[str]],
headers: Iterable[str],
) -> Iterator[Tuple[str, "CellT"]]:
from dvc.compare import _format_field, with_value
for fname, data in d.items():
item = data.get("data", {})
item = flatten(item) if isinstance(item, dict) else {fname: item}
for name in names[fname]:
value = with_value(
item.get(name),
error_value if data.get("error") else fill_value,
)
# wrap field data in ui.rich_text, otherwise rich may
# interpret unescaped braces from list/dict types as rich
# markup tags
value = ui.rich_text(str(_format_field(value, precision)))
if name in headers:
yield name, value
else:
yield f"{fname}:{name}", value
if not exp.data:
return
yield from _d_cells(exp.data.metrics, metrics_names, metrics_headers)
yield from _d_cells(exp.data.params, params_names, params_headers)
for name in deps_names:
dep = exp.data.deps.get(name)
if dep:
yield name, dep.hash or fill_value
def format_time(
timestamp: Optional[datetime],
fill_value: Optional[str] = "-",
iso: bool = False,
**kwargs,
) -> Optional[str]:
if not timestamp:
return fill_value
if iso:
return timestamp.isoformat()
if timestamp.date() == date.today():
fmt = "%I:%M %p"
else:
fmt = "%b %d, %Y"
return timestamp.strftime(fmt)
class _DataNames(NamedTuple):
# NOTE: we use nested dict instead of set for metrics/params names to
# preserve key ordering
metrics: Dict[str, Dict[str, Any]]
params: Dict[str, Dict[str, Any]]
deps: Set[str]
@property
def sorted_deps(self):
return sorted(self.deps)
def update(self, other: "_DataNames"):
def _update_d(
d: Dict[str, Dict[str, Any]], other_d: Mapping[str, Mapping[str, Any]]
):
collected_exp = collect_experiment_commit(
repo,
stash_rev,
status=ExpStatus.Running,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
result[entry.baseline_rev][stash_rev] = collected_exp
for k, v in other_d.items():
if k in d:
d[k].update(v)
else:
d[k] = dict(v)
_update_d(self.metrics, other.metrics)
_update_d(self.params, other.params)
self.deps.update(other.deps)
def _collect_names(exp_states: Iterable["ExpState"]) -> _DataNames:
result = _DataNames(defaultdict(dict), defaultdict(dict), set())
def _collect_d(result_d: Dict[str, Dict[str, Any]], data_d: Dict[str, Any]):
for path, item in data_d.items():
item = item.get("data", {})
if isinstance(item, dict):
item = flatten(item)
result_d[path].update((key, None) for key in item)
for exp in exp_states:
if exp.data:
_collect_d(result.metrics, exp.data.metrics)
_collect_d(result.params, exp.data.params)
result.deps.update(exp.data.deps)
if exp.experiments:
for child in exp.experiments:
result.update(_collect_names(child.revs))
return result
def _collect_queued_experiment(
repo: "Repo",
found_revs: Dict[str, List[str]],
running: Dict[str, Any],
**kwargs,
) -> Dict[str, Dict[str, Any]]:
result: Dict[str, Dict] = defaultdict(OrderedDict)
for entry in repo.experiments.celery_queue.iter_queued():
stash_rev = entry.stash_rev
if entry.baseline_rev in found_revs:
collected_exp = collect_experiment_commit(
repo,
stash_rev,
status=ExpStatus.Queued,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
result[entry.baseline_rev][stash_rev] = collected_exp
return result
def _collect_failed_experiment(
repo: "Repo",
found_revs: Dict[str, List[str]],
running: Dict[str, Any],
**kwargs,
) -> Dict[str, Dict[str, Any]]:
result: Dict[str, Dict] = defaultdict(OrderedDict)
for queue_done_result in repo.experiments.celery_queue.iter_failed():
entry = queue_done_result.entry
stash_rev = entry.stash_rev
if entry.baseline_rev in found_revs:
collected_exp = collect_experiment_commit(
repo,
stash_rev,
status=ExpStatus.Failed,
running=running,
**kwargs,
)
if _is_scm_error(collected_exp):
continue
result[entry.baseline_rev][stash_rev] = collected_exp
return result
def update_new(
to_dict: Dict[str, Dict[str, Any]], from_dict: Dict[str, Dict[str, Any]]
):
for baseline, experiments in from_dict.items():
for rev, experiment in experiments.items():
to_dict[baseline][rev] = to_dict[baseline].get(rev, experiment)
def move_properties_to_head(result: Dict[str, Dict[str, Dict[str, Any]]]):
for _, baseline_results in result.items():
checkpoint: bool = False
head: Dict[str, Any] = {}
for rev, rev_data in baseline_results.items():
if (
"data" not in rev_data
or rev_data["data"].get("checkpoint_tip", None) is None
):
checkpoint = False
head = {}
continue
rev_result: Dict[str, Any] = rev_data["data"]
if (
checkpoint is True
and rev_result["checkpoint_tip"] == head["checkpoint_tip"]
):
if "name" in rev_result and "name" not in head:
head["name"] = rev_result["name"]
del rev_result["name"]
if rev_result["executor"]:
if not head["executor"]:
head["executor"] = rev_result["executor"]
rev_result["executor"] = None
if rev_result["status"] == ExpStatus.Running.name:
head["status"] = ExpStatus.Running.name
rev_result["status"] = ExpStatus.Success.name
elif rev_result["checkpoint_tip"] == rev:
head = rev_result
checkpoint = True
def show( # noqa: PLR0913
repo: "Repo",
all_branches=False,
all_tags=False,
revs: Union[List[str], str, None] = None,
all_commits=False,
hide_queued=False,
hide_failed=False,
sha_only=False,
num=1,
param_deps=False,
onerror: Optional[Callable] = None,
fetch_running: bool = True,
force: bool = False,
):
if repo.scm.no_commits:
return {}
res: Dict[str, Dict] = defaultdict(OrderedDict)
if not any([revs, all_branches, all_tags, all_commits]):
revs = ["HEAD"]
if isinstance(revs, str):
revs = [revs]
assert isinstance(repo.scm, Git)
found_revs: Dict[str, List[str]] = {"workspace": []}
found_revs.update(
iter_revs(repo.scm, revs, num, all_branches, all_tags, all_commits)
)
cached_refs = list(repo.scm.iter_refs())
branch_names = get_branch_names(repo.scm, found_revs, refs=cached_refs)
running: Dict[str, Dict] = repo.experiments.get_running_exps(
fetch_refs=fetch_running
)
queued_experiment = (
_collect_queued_experiment(
repo,
found_revs,
running,
param_deps=param_deps,
force=force,
)
if not hide_queued
else {}
)
active_experiment = _collect_active_experiment(
repo,
found_revs,
running,
param_deps=param_deps,
onerror=onerror,
force=force,
)
failed_experiments = (
_collect_failed_experiment(
repo,
found_revs,
running,
param_deps=param_deps,
onerror=onerror,
force=force,
)
if not hide_failed
else {}
)
for baseline in found_revs:
res[baseline] = _collect_branch(
repo,
baseline,
running=running,
param_deps=param_deps,
onerror=onerror,
force=force,
refs=cached_refs,
)
update_new(res, failed_experiments)
update_new(res, active_experiment)
update_new(res, queued_experiment)
if not sha_only:
update_names(repo, branch_names, res)
move_properties_to_head(res)
return res
def _normalize_headers(
names: Mapping[str, Mapping[str, Any]], count: Mapping[str, int]
) -> List[str]:
return [
name if count[name] == 1 else f"{path}:{name}"
for path in names
for name in names[path]
]
File diff suppressed because one or more lines are too long
+53 -13
View File
@@ -1,5 +1,5 @@
import logging
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
from dvc.exceptions import InvalidArgumentError
@@ -7,11 +7,22 @@ from . import locked
if TYPE_CHECKING:
from dvc.repo import Repo
from dvc_data.hashfile.hash_info import HashInfo
from dvc_objects.db import ObjectDB
logger = logging.getLogger(__name__)
def _validate_args(**kwargs):
not_in_remote = kwargs.pop("not_in_remote", None)
cloud = kwargs.pop("cloud", None)
remote = kwargs.pop("remote", None)
if remote and not (cloud or not_in_remote):
raise InvalidArgumentError("`--remote` requires `--cloud` or `--not-in-remote`")
if not_in_remote and cloud:
raise InvalidArgumentError(
"`--not-in-remote` and `--cloud` are mutually exclusive"
)
if not any(kwargs.values()):
raise InvalidArgumentError(
"Either of `-w|--workspace`, `-a|--all-branches`, `-T|--all-tags` "
@@ -22,8 +33,22 @@ def _validate_args(**kwargs):
raise InvalidArgumentError("`--num` can only be used alongside `--rev`")
def _used_obj_ids_not_in_remote(repo, default_remote, jobs, remote_odb_to_obj_ids):
used_obj_ids = set()
remote_oids = set()
for remote_odb, obj_ids in remote_odb_to_obj_ids:
if remote_odb is None:
remote_odb = repo.cloud.get_remote_odb(default_remote, "gc --not-in-remote")
remote_oids.update(
remote_odb.list_oids_exists({x.value for x in obj_ids}, jobs=jobs)
)
used_obj_ids.update(obj_ids)
return {obj for obj in used_obj_ids if obj.value not in remote_oids}
@locked
def gc( # noqa: PLR0913
def gc( # noqa: PLR0912, PLR0913, C901
self: "Repo",
all_branches: bool = False,
cloud: bool = False,
@@ -39,6 +64,7 @@ def gc( # noqa: PLR0913
commit_date: Optional[str] = None,
rev: Optional[str] = None,
num: Optional[int] = None,
not_in_remote: bool = False,
):
# require `workspace` to be true to come into effect.
# assume `workspace` to be enabled if any of `all_tags`, `all_commits`,
@@ -52,6 +78,8 @@ def gc( # noqa: PLR0913
commit_date=commit_date,
rev=rev,
num=num,
cloud=cloud,
not_in_remote=not_in_remote,
)
from contextlib import ExitStack
@@ -64,13 +92,14 @@ def gc( # noqa: PLR0913
repos = []
all_repos = [Repo(path) for path in repos]
used_obj_ids = set()
odb_to_obj_ids: List[Tuple[Optional["ObjectDB"], Set["HashInfo"]]] = []
with ExitStack() as stack:
for repo in all_repos:
stack.enter_context(repo.lock)
for repo in [*all_repos, self]:
for obj_ids in repo.used_objs(
for odb, obj_ids in repo.used_objs(
all_branches=all_branches,
with_deps=with_deps,
all_tags=all_tags,
@@ -82,13 +111,22 @@ def gc( # noqa: PLR0913
jobs=jobs,
revs=[rev] if rev else None,
num=num or 1,
).values():
used_obj_ids.update(obj_ids)
).items():
odb_to_obj_ids.append((odb, obj_ids))
if not odb_to_obj_ids:
odb_to_obj_ids = [(None, set())]
if not_in_remote:
used_obj_ids = _used_obj_ids_not_in_remote(self, remote, jobs, odb_to_obj_ids)
else:
used_obj_ids = set()
for _, obj_ids in odb_to_obj_ids:
used_obj_ids.update(obj_ids)
for scheme, odb in self.cache.by_scheme():
if not odb:
continue
removed = ogc(odb, used_obj_ids, jobs=jobs)
if not removed:
logger.info("No unused '%s' cache to remove.", scheme)
@@ -96,9 +134,11 @@ def gc( # noqa: PLR0913
if not cloud:
return
odb = self.cloud.get_remote_odb(remote, "gc -c")
removed = ogc(odb, used_obj_ids, jobs=jobs)
if removed:
get_index(odb).clear()
else:
logger.info("No unused cache to remove from remote.")
for remote_odb, obj_ids in odb_to_obj_ids:
if remote_odb is None:
remote_odb = repo.cloud.get_remote_odb(remote, "gc -c")
removed = ogc(remote_odb, obj_ids, jobs=jobs)
if removed:
get_index(remote_odb).clear()
else:
logger.info("No unused cache to remove from remote.")
+27 -53
View File
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Union
from dvc.exceptions import DvcException
from dvc.utils import resolve_output
from dvc.utils.fs import remove
if TYPE_CHECKING:
from dvc.fs.dvc import DVCFileSystem
@@ -20,64 +19,39 @@ class GetDVCFileError(DvcException):
)
def get(url, path, out=None, rev=None, jobs=None):
import shortuuid
def get(url, path, out=None, rev=None, jobs=None, force=False):
from dvc.dvcfile import is_valid_filename
from dvc.external_repo import external_repo
from dvc.fs.callbacks import Callback
from dvc.repo import Repo
out = resolve_output(path, out)
out = resolve_output(path, out, force=force)
if is_valid_filename(out):
raise GetDVCFileError()
# Creating a directory right beside the output to make sure that they
# are on the same filesystem, so we could take the advantage of
# reflink and/or hardlink. Not using tempfile.TemporaryDirectory
# because it will create a symlink to tmpfs, which defeats the purpose
# and won't work with reflink/hardlink.
dpath = os.path.dirname(os.path.abspath(out))
tmp_dir = os.path.join(dpath, "." + str(shortuuid.uuid()))
with Repo.open(
url=url,
rev=rev,
subrepos=True,
uninitialized=True,
) as repo:
from dvc.fs.data import DataFileSystem
# Try any links possible to avoid data duplication.
#
# Not using symlink, because we need to remove cache after we
# are done, and to make that work we would have to copy data
# over anyway before removing the cache, so we might just copy
# it right away.
#
# Also, we can't use theoretical "move" link type here, because
# the same cache file might be used a few times in a directory.
cache_types = ["reflink", "hardlink", "copy"]
try:
with external_repo(
url=url,
rev=rev,
subrepos=True,
uninitialized=True,
cache_dir=tmp_dir,
cache_types=cache_types,
) as repo:
from dvc.fs.data import DataFileSystem
fs: Union[DataFileSystem, "DVCFileSystem"]
if os.path.isabs(path):
fs = DataFileSystem(index=repo.index.data["local"])
fs_path = fs.from_os_path(path)
else:
fs = repo.dvcfs
fs_path = fs.from_os_path(path)
fs: Union[DataFileSystem, "DVCFileSystem"]
if os.path.isabs(path):
fs = DataFileSystem(index=repo.index.data["local"])
fs_path = fs.from_os_path(path)
else:
fs = repo.dvcfs
fs_path = fs.from_os_path(path)
with Callback.as_tqdm_callback(
desc=f"Downloading {fs.path.name(path)}",
unit="files",
) as cb:
fs.get(
fs_path,
os.path.abspath(out),
batch_size=jobs,
callback=cb,
)
finally:
remove(tmp_dir)
with Callback.as_tqdm_callback(
desc=f"Downloading {fs.path.name(path)}",
unit="files",
) as cb:
fs.get(
fs_path,
os.path.abspath(out),
batch_size=jobs,
callback=cb,
)
+2 -2
View File
@@ -6,8 +6,8 @@ from dvc.fs import download, parse_external_url
from dvc.utils import resolve_output
def get_url(url, out=None, *, config=None, jobs=None):
out = resolve_output(url, out)
def get_url(url, out=None, *, config=None, jobs=None, force=False):
out = resolve_output(url, out, force=force)
out = os.path.abspath(out)
(out,) = output.loads_from(None, [out], use_cache=False)
+5 -3
View File
@@ -30,15 +30,17 @@ def imp_url( # noqa: C901, PLR0913
labels=None,
meta=None,
jobs=None,
force=False,
fs_config=None,
version_aware: bool = False,
):
out = resolve_output(url, out)
out = resolve_output(url, out, force=force)
path, wdir, out = resolve_paths(self, out, always_local=to_remote and not out)
if to_remote and (no_exec or no_download):
if to_remote and (no_exec or no_download or version_aware):
raise InvalidArgumentError(
"--no-exec/--no-download cannot be combined with --to-remote"
"--no-exec/--no-download/--version-aware cannot be combined with "
"--to-remote"
)
if not to_remote and remote:
+1 -1
View File
@@ -47,7 +47,7 @@ def _collect_metrics(repo, targets, recursive):
def _extract_metrics(metrics, path, rev):
if isinstance(metrics, (int, float)):
if isinstance(metrics, (int, float, str)):
return metrics
if not isinstance(metrics, dict):
+21 -13
View File
@@ -3,7 +3,7 @@ import os
import tempfile
import threading
from contextlib import contextmanager
from typing import TYPE_CHECKING, Collection, Dict, Iterator, Optional, Tuple
from typing import TYPE_CHECKING, Dict, Iterator, Optional, Tuple
from funcy import retry, wrap_with
@@ -13,21 +13,17 @@ from dvc.scm import CloneError, map_scm_exception
from dvc.utils import relpath
if TYPE_CHECKING:
from dvc.types import StrPath
from .scm import Git
from dvc.scm import Git
logger = logging.getLogger(__name__)
@contextmanager
@map_scm_exception()
def external_repo(
def _external_repo(
url,
rev: Optional[str] = None,
for_write: bool = False,
cache_dir: Optional["StrPath"] = None,
cache_types: Optional[Collection[str]] = None,
**kwargs,
) -> Iterator["Repo"]:
logger.debug("Creating external repo %s@%s", url, rev)
@@ -37,12 +33,8 @@ def external_repo(
# the tip of the default branch
rev = rev or "refs/remotes/origin/HEAD"
cache_config = {
"cache": {"dir": cache_dir or _get_cache_dir(url), "type": cache_types}
}
config = _get_remote_config(url) if os.path.isdir(url) else {}
config.update(cache_config)
config.update({"cache": {"dir": _get_cache_dir(url)}})
config.update(kwargs.pop("config", None) or {})
main_root = "/"
@@ -55,7 +47,7 @@ def external_repo(
root_dir=path,
url=url,
config=config,
repo_factory=erepo_factory(url, main_root, cache_config),
repo_factory=erepo_factory(url, main_root, {"cache": config["cache"]}),
rev=rev,
**kwargs,
)
@@ -70,6 +62,22 @@ def external_repo(
_remove(path)
def open_repo(url, *args, **kwargs):
if url is None:
url = os.getcwd()
if os.path.exists(url):
try:
config = _get_remote_config(url)
config.update(kwargs.get("config") or {})
kwargs["config"] = config
return Repo(url, *args, **kwargs)
except NotDvcRepoError:
pass # fallthrough to _external_repo
return _external_repo(url, *args, **kwargs)
def erepo_factory(url, root_dir, cache_config):
from dvc.fs import localfs
+33 -26
View File
@@ -121,51 +121,58 @@ class Plots:
}
"""
from dvc.repo.experiments.brancher import switch_repo
from dvc.utils.collections import ensure_list
targets = ensure_list(targets)
targets = [self.repo.dvcfs.from_os_path(target) for target in targets]
for rev in self.repo.brancher(revs=revs):
# .brancher() adds unwanted workspace
if revs is not None and rev not in revs:
continue
rev = rev or "workspace"
res: Dict = {}
definitions = _collect_definitions(
self.repo,
targets=targets,
revision=rev,
onerror=onerror,
props=props,
)
if definitions:
res[rev] = {"definitions": definitions}
data_targets = _get_data_targets(definitions)
res[rev]["sources"] = self._collect_data_sources(
targets=data_targets,
recursive=recursive,
props=props,
if revs is None:
revs = ["workspace"]
else:
revs = list(revs)
if "workspace" in revs:
# reorder revs to match repo.brancher ordering
revs.remove("workspace")
revs = ["workspace"] + revs
for rev in revs:
with switch_repo(self.repo, rev) as (repo, _):
res: Dict = {}
definitions = _collect_definitions(
repo,
targets=targets,
revision=rev,
onerror=onerror,
props=props,
)
yield res
if definitions:
res[rev] = {"definitions": definitions}
data_targets = _get_data_targets(definitions)
res[rev]["sources"] = self._collect_data_sources(
repo,
targets=data_targets,
recursive=recursive,
props=props,
onerror=onerror,
)
yield res
@error_handler
def _collect_data_sources(
self,
repo: "Repo",
targets: Optional[List[str]] = None,
recursive: bool = False,
props: Optional[Dict] = None,
onerror: Optional[Callable] = None,
):
fs = self.repo.dvcfs
fs = repo.dvcfs
props = props or {}
plots = _collect_plots(self.repo, targets, recursive)
plots = _collect_plots(repo, targets, recursive)
res: Dict[str, Any] = {}
for fs_path, rev_props in plots.items():
joined_props = {**rev_props, **props}
+16 -18
View File
@@ -3,9 +3,8 @@ from functools import partial
from typing import TYPE_CHECKING, Iterator, List
from dvc.exceptions import DvcException, ReproductionError
from dvc.repo.metrics.show import _collect_top_level_metrics
from dvc.repo.params.show import _collect_top_level_params
from dvc.repo.scm_context import scm_context
from dvc.stage.cache import RunCacheNotSupported
from dvc.stage.exceptions import CheckpointKilledError
from . import locked
@@ -76,20 +75,9 @@ def _track_stage(stage: "Stage") -> None:
return context.track_changed_files()
def _track_top_level(repo: "Repo") -> None:
context = repo.scm_context
for metric_file in _collect_top_level_metrics(repo):
context.track_file(metric_file)
for param_file in _collect_top_level_params(repo):
context.track_file(param_file)
for plot_file in repo.index._plot_sources: # pylint: disable=protected-access
context.track_file(plot_file)
context.track_changed_files()
@locked
@scm_context
def reproduce( # noqa: C901
def reproduce( # noqa: C901, PLR0912
self: "Repo",
targets=None,
recursive=False,
@@ -138,13 +126,23 @@ def reproduce( # noqa: C901
)
)
result = _reproduce_stages(self.index.graph, list(stages), **kwargs)
_track_top_level(self)
return result
if kwargs.get("pull", False) and kwargs.get("run_cache", True):
logger.debug("Pulling run cache")
try:
self.stage_cache.pull(None)
except RunCacheNotSupported as e:
logger.warning("Failed to pull run cache: %s", e)
return _reproduce_stages(self.index.graph, list(stages), **kwargs)
def _reproduce_stages( # noqa: C901
graph, stages, downstream=False, single_item=False, on_unchanged=None, **kwargs
graph,
stages,
downstream=False,
single_item=False,
on_unchanged=None,
**kwargs,
):
r"""Derive the evaluation of the given node for the given graph.
+9 -10
View File
@@ -122,16 +122,15 @@ class StageLoad:
force=force,
**stage_data,
)
with self.repo.scm_context:
stage.dump(update_lock=update_lock)
try:
stage.ignore_outs()
except FileNotFoundError as exc:
ui.warn(
f"Could not create .gitignore entry in {exc.filename}."
" DVC will attempt to create .gitignore entry again when"
" the stage is run."
)
stage.dump(update_lock=update_lock)
try:
stage.ignore_outs()
except FileNotFoundError as exc:
ui.warn(
f"Could not create .gitignore entry in {exc.filename}."
" DVC will attempt to create .gitignore entry again when"
" the stage is run."
)
return stage
+2 -1
View File
@@ -173,7 +173,7 @@ def push_worktree(
) as cb:
cb.set_size(total)
try:
pushed += checkout(
stats = checkout(
new_index,
remote_obj.path,
remote_obj.fs,
@@ -184,6 +184,7 @@ def push_worktree(
jobs=jobs,
**diff_kwargs,
)
pushed += sum(len(changes) for changes in stats.values())
except VersioningNotSupported:
logger.exception("")
raise DvcException(
+1 -2
View File
@@ -116,7 +116,6 @@ PLOT_DEFINITION = {
SINGLE_PLOT_SCHEMA = {str: Any(PLOT_DEFINITION, None)}
ARTIFACTS = "artifacts"
SINGLE_ARTIFACT_SCHEMA = Schema({str: ARTIFACT_SCHEMA})
ARTIFACTS_SCHEMA = Any(str, SINGLE_ARTIFACT_SCHEMA)
FOREACH_IN = {
Required(FOREACH_KWD): Any(dict, list, str),
Required(DO_KWD): STAGE_DEFINITION,
@@ -130,7 +129,7 @@ MULTI_STAGE_SCHEMA = {
VARS_KWD: VARS_SCHEMA,
StageParams.PARAM_PARAMS: [str],
StageParams.PARAM_METRICS: [str],
ARTIFACTS: ARTIFACTS_SCHEMA,
ARTIFACTS: SINGLE_ARTIFACT_SCHEMA,
}
COMPILED_SINGLE_STAGE_SCHEMA = Schema(SINGLE_STAGE_SCHEMA)
+66 -31
View File
@@ -319,20 +319,22 @@ class Stage(params.StageParams):
env.update(current)
return env
def changed_deps(self) -> bool:
def changed_deps(self, allow_missing: bool = False) -> bool:
if self.frozen:
return False
if self.is_callback or self.always_changed or self.is_checkpoint:
return True
return self._changed_deps()
return self._changed_deps(allow_missing=allow_missing)
@rwlocked(read=["deps"])
def _changed_deps(self) -> bool:
def _changed_deps(self, allow_missing: bool = False) -> bool:
for dep in self.deps:
status = dep.status()
if status:
if allow_missing and status[str(dep)] == "deleted":
continue
logger.debug(
"Dependency '%s' of %s changed because it is '%s'.",
dep,
@@ -343,10 +345,12 @@ class Stage(params.StageParams):
return False
@rwlocked(read=["outs"])
def changed_outs(self) -> bool:
def changed_outs(self, allow_missing: bool = False) -> bool:
for out in self.outs:
status = out.status()
if status:
if allow_missing and status[str(out)] == "not in cache":
continue
logger.debug(
"Output '%s' of %s changed because it is '%s'.",
out,
@@ -364,13 +368,13 @@ class Stage(params.StageParams):
return changed
@rwlocked(read=["deps", "outs"])
def changed(self) -> bool:
def changed(self, allow_missing: bool = False) -> bool:
is_changed = (
# Short-circuit order: stage md5 is fast,
# deps are expected to change
self.changed_stage()
or self.changed_deps()
or self.changed_outs()
or self.changed_deps(allow_missing=allow_missing)
or self.changed_outs(allow_missing=allow_missing)
)
if is_changed:
logger.debug("%s changed.", self)
@@ -416,12 +420,15 @@ class Stage(params.StageParams):
(out,) = self.outs
out.transfer(source, odb=odb, jobs=kwargs.get("jobs"))
if not to_remote:
out.checkout()
out.checkout(force=kwargs.get("force"))
out.ignore()
@rwlocked(read=["deps"], write=["outs"])
def reproduce(self, interactive=False, **kwargs) -> Optional["Stage"]:
if not (kwargs.get("force", False) or self.changed()):
if not (
kwargs.get("force", False)
or self.changed(kwargs.get("allow_missing", False))
):
if not isinstance(self, PipelineStage) and self.is_data_source:
logger.info("'%s' didn't change, skipping", self.addressing)
else:
@@ -495,34 +502,34 @@ class Stage(params.StageParams):
if not allow_missing:
raise
def save_outs(self, allow_missing: bool = False):
from dvc.output import OutputDoesNotExistError
def get_versioned_outs(self) -> Dict[str, "Output"]:
from .exceptions import StageFileDoesNotExistError, StageNotFound
try:
old = self.reload()
old_outs = {out.def_path: out for out in old.outs}
merge_versioned = any(
(
out.files is not None
or (out.meta is not None and out.meta.version_id is not None)
)
for out in old_outs.values()
)
except (StageFileDoesNotExistError, StageNotFound):
merge_versioned = False
return {}
return {
out.def_path: out
for out in old.outs
if out.files is not None
or (out.meta is not None and out.meta.version_id is not None)
}
def save_outs(self, allow_missing: bool = False):
from dvc.output import OutputDoesNotExistError
old_versioned_outs = self.get_versioned_outs()
for out in self.outs:
try:
out.save()
except OutputDoesNotExistError:
if not (allow_missing or out.checkpoint):
raise
if merge_versioned:
old_out = old_outs.get(out.def_path)
if old_out is not None:
out.merge_version_meta(old_out)
if old_out := old_versioned_outs.get(out.def_path):
out.merge_version_meta(old_out)
def ignore_outs(self) -> None:
for out in self.outs:
@@ -560,8 +567,31 @@ class Stage(params.StageParams):
if link_failures:
raise CacheLinkError(link_failures)
@rwlocked(write=["outs"])
def add_outs( # noqa: C901
self, filter_info=None, allow_missing: bool = False, **kwargs
):
from dvc.output import OutputDoesNotExistError
link_failures = []
old_versioned_outs = self.get_versioned_outs()
for out in self.filter_outs(filter_info):
try:
out.add(filter_info, **kwargs)
except (FileNotFoundError, OutputDoesNotExistError):
if not (allow_missing or out.checkpoint):
raise
except CacheLinkError:
link_failures.append(filter_info or out.fs_path)
if old_out := old_versioned_outs.get(out.def_path):
out.merge_version_meta(old_out)
if link_failures:
raise CacheLinkError(link_failures)
@rwlocked(read=["deps", "outs"])
def run(
def run( # noqa: C901
self,
dry=False,
no_commit=False,
@@ -573,15 +603,20 @@ class Stage(params.StageParams):
if (self.cmd or self.is_import) and not self.frozen and not dry:
self.remove_outs(ignore_remove=False, force=False)
if (not self.frozen and self.is_import) or self.is_partial_import:
if (
self.is_import and (not self.frozen or kwargs.get("pull"))
) or self.is_partial_import:
self._sync_import(dry, force, kwargs.get("jobs", None), no_download)
elif not self.frozen and self.cmd:
self._run_stage(dry, force, **kwargs)
else:
self._run_stage(dry, force, allow_missing=allow_missing, **kwargs)
elif kwargs.get("pull"):
logger.info("Pulling data for %s", self)
self.repo.pull(self.addressing, jobs=kwargs.get("jobs", None))
self.checkout()
elif not dry:
args = ("outputs", "frozen ") if self.frozen else ("data sources", "")
logger.info("Verifying %s in %s%s", *args, self)
if not dry:
self._check_missing_outputs()
self._check_missing_outputs()
if not dry:
if kwargs.get("checkpoint_func", None) or no_download:
+16 -1
View File
@@ -133,10 +133,25 @@ def cmd_run(stage, dry=False, checkpoint_func=None, run_env=None):
_run(stage, executable, cmd, checkpoint_func=checkpoint_func, **kwargs)
def _pull_missing_deps(stage):
for dep in stage.deps:
if not dep.exists:
stage.repo.pull(dep.def_path)
def run_stage(
stage, dry=False, force=False, checkpoint_func=None, run_env=None, **kwargs
stage,
dry=False,
force=False,
checkpoint_func=None,
run_env=None,
allow_missing: bool = False,
**kwargs,
):
if not (force or checkpoint_func):
if allow_missing and kwargs.get("pull") and not dry:
_pull_missing_deps(stage)
from .cache import RunCacheNotFoundError
try:
+19
View File
@@ -0,0 +1,19 @@
DVC pytest plugin
dvc.testing.benchmarks
======================
Benchmark test definitions are now part of ``dvc.testing``.
For CLI usage and `bench.dvc.org <https://bench.dvc.org>`_ details see `dvc-bench <https://github.com/iterative/dvc-bench>`_.
``dvc.testing.benchmarks`` structure:
* cli: should be able to run these with any dvc (rpm, deb, pypi, snap, etc) (could be used in dvc-test repo too)
* commands: granular tests for individual commands. These should have a cached setup, so that we could use them during rapid development instead of our hand-written scripts. Every test could be run in a separate machine.
* stories: multistage start-to-end benchmarks, useful for testing workflows (e.g. in documentation, see test_sharing inspired by `Storing and sharing <https://dvc.org/doc/start/data-management/data-versioning#storing-and-sharing>`_. Every full story could be run in a separate machine.
* api: for python api only.
* methods: granular tests for individual methods (e.g. ``api.open/read``). Same reasoning as in ``cli.commands``
* stories: same as ``cli.stories`` but for our api. E.g. imagine using our api with pandas or smth like that.
View File
@@ -0,0 +1,3 @@
# pylint: disable=unused-argument
def test_add(bench_dvc, tmp_dir, dvc, dataset):
bench_dvc("add", dataset)
@@ -0,0 +1,39 @@
# pylint: disable=unused-argument
import os
import pytest
from dvc.fs import localfs
from dvc_objects.fs import generic
from dvc_objects.fs.utils import tmp_fname
def _skip_unsupported_link(src, dest, link_type):
src_test_file = os.path.join(src, tmp_fname())
dest_test_file = os.path.join(dest, tmp_fname())
if not generic.test_links(
[link_type], localfs, src_test_file, localfs, dest_test_file
):
pytest.skip(f"{link_type} not supported")
def generate_test(*, link_type="copy"):
def _test_checkout_func(bench_dvc, tmp_dir, dvc, make_dataset):
dataset = make_dataset(dvcfile=True, cache=True, files=False)
_skip_unsupported_link((tmp_dir / ".dvc" / "cache"), tmp_dir, link_type)
with dvc.config.edit() as conf:
conf["cache"]["type"] = link_type
bench_dvc("checkout", dataset)
bench_dvc("checkout", name="noop")
(dataset / "new").write_text("new")
bench_dvc("checkout", "--force", name="update")
return _test_checkout_func
test_checkout_copy = generate_test(link_type="copy")
test_checkout_symlink = generate_test(link_type="symlink")
test_checkout_hardlink = generate_test(link_type="hardlink")
@@ -0,0 +1,49 @@
# pylint: disable=unused-argument
from shutil import rmtree
import pytest
pytestmark = pytest.mark.requires(minversion=(2, 15, 0), reason="new command")
def test_data_status(
bench_dvc,
tmp_dir,
scm,
dvc,
make_dataset,
):
args = ("data", "status")
dataset = make_dataset(cache=True, files=True, dvcfile=True, commit=False)
rmtree(dvc.tmp_dir)
bench_dvc(*args, name="new")
bench_dvc(*args, name="noop")
tmp_dir.scm_add(dataset.with_suffix(".dvc").name, commit="add dataset")
(dataset / "new").write_text("new")
bench_dvc(*args, name="changed")
bench_dvc(*args, name="changed-noop")
def test_data_status_all_flags(bench_dvc, tmp_dir, scm, dvc, make_dataset):
args = (
"data",
"status",
"--granular",
"--unchanged",
"--untracked-files",
"--json",
)
dataset = make_dataset(cache=True, files=True, dvcfile=True, commit=False)
rmtree(dvc.tmp_dir)
bench_dvc(*args, name="new")
bench_dvc(*args, name="noop")
tmp_dir.scm_add(dataset.with_suffix(".dvc").name, commit="add dataset")
(dataset / "new").write_text("new")
bench_dvc(*args, name="changed")
bench_dvc(*args, name="changed-noop")
@@ -0,0 +1,9 @@
# pylint: disable=unused-argument
def test_diff(bench_dvc, tmp_dir, scm, dvc, make_dataset):
dataset = make_dataset(cache=True, files=True, dvcfile=True, commit=True)
bench_dvc("diff")
bench_dvc("diff", name="noop")
(dataset / "new").write_text("new")
bench_dvc("diff", name="changed")
bench_dvc("diff", name="changed-noop")
@@ -0,0 +1,14 @@
# pylint: disable=unused-argument
import pytest
@pytest.mark.requires(minversion=(2, 10, 0))
def test_exp_show(make_project, monkeypatch, bench_dvc, dvc_bin):
url = "https://github.com/efiop/lstm_seq2seq"
rev = "dvc-bench"
path = make_project(url, rev=rev)
monkeypatch.chdir(path)
dvc_bin("exp", "pull", "-A", "--no-cache", "origin")
bench_dvc("exp", "show", "-A", "--no-pager")
@@ -0,0 +1,4 @@
# pylint: disable=unused-argument
def test_gc(bench_dvc, tmp_dir, dvc, make_dataset):
make_dataset(files=False, cache=True, dvcfile=False)
bench_dvc("gc", "-f", "-w")
@@ -0,0 +1,6 @@
# pylint: disable=unused-argument
def test_get(bench_dvc, tmp_dir, scm, dvc, make_dataset, remote):
dataset = make_dataset(
cache=False, files=False, dvcfile=True, commit=True, remote=True
)
bench_dvc("get", tmp_dir, dataset.name, "-o", "new")
@@ -0,0 +1,6 @@
# pylint: disable=unused-argument
def test_get_url(bench_dvc, tmp_dir, scm, dvc, make_dataset):
dataset = make_dataset(
cache=False, files=True, dvcfile=False, commit=False, remote=False
)
bench_dvc("get-url", str(dataset), "new")
@@ -0,0 +1,2 @@
def test_help(bench_dvc):
bench_dvc("--help", rounds=100)
@@ -0,0 +1,6 @@
# pylint: disable=unused-argument
def test_import(bench_dvc, tmp_dir, scm, dvc, make_dataset, remote):
dataset = make_dataset(
cache=False, files=False, dvcfile=True, commit=True, remote=True
)
bench_dvc("import", tmp_dir, dataset.name, "-o", "new")
@@ -0,0 +1,6 @@
# pylint: disable=unused-argument
def test_import_url(bench_dvc, tmp_dir, scm, dvc, make_dataset):
dataset = make_dataset(
cache=False, files=True, dvcfile=False, commit=False, remote=False
)
bench_dvc("import-url", str(dataset), "new")
@@ -0,0 +1,14 @@
# pylint: disable=unused-argument
import shutil
def test_init(bench_dvc, tmp_dir, scm):
def _cleanup_dir():
for item in tmp_dir.iterdir():
if item.is_dir():
if item.name != ".git":
shutil.rmtree(item)
else:
item.unlink()
bench_dvc("init", setup=_cleanup_dir, rounds=100, warmup_rounds=1)
@@ -0,0 +1,7 @@
# pylint: disable=unused-argument
def test_list(bench_dvc, tmp_dir, scm, dvc, make_dataset, remote):
make_dataset(cache=False, files=False, dvcfile=True, commit=True, remote=True)
bench_dvc("list", tmp_dir)
bench_dvc("list", tmp_dir, "--dvc-only", name="dvc-only")
bench_dvc("list", tmp_dir, "--recursive", name="recursive")
bench_dvc("list", tmp_dir, "dataset", name="shallow")
@@ -0,0 +1,20 @@
import pytest
from dvc.repo import Repo
from dvc.testing.benchmarks.fixtures import _pull
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=unused-argument
@pytest.mark.requires(minversion=(2, 34, 0), reason="top-level plots not supported")
def test_plots(project, bench_dvc):
with Repo() as dvc:
_pull(dvc)
kwargs = {"rounds": 5, "iterations": 3, "warmup_rounds": 2}
bench_dvc("plots", "show", name="show", **kwargs)
bench_dvc("plots", "show", "--json", name="show-json", **kwargs)
bench_dvc("plots", "diff", "HEAD", name="diff", **kwargs)
bench_dvc("plots", "diff", "HEAD", "--json", name="diff-json", **kwargs)
@@ -0,0 +1,4 @@
# pylint: disable=unused-argument
def test_pull(bench_dvc, tmp_dir, dvc, make_dataset, remote):
make_dataset(cache=False, dvcfile=True, files=False, remote=True)
bench_dvc("pull")
@@ -0,0 +1,4 @@
# pylint: disable=unused-argument
def test_push(bench_dvc, tmp_dir, dvc, make_dataset, remote):
make_dataset(cache=True, dvcfile=True, files=False)
bench_dvc("push")
@@ -0,0 +1,9 @@
# pylint: disable=unused-argument
def test_status(bench_dvc, tmp_dir, dvc, make_dataset):
dataset = make_dataset(files=True, dvcfile=True, cache=True)
bench_dvc("status")
bench_dvc("status", name="noop")
(dataset / "new").write_text("new")
bench_dvc("status", name="changed")
bench_dvc("status", name="changed-noop")
@@ -0,0 +1,9 @@
# pylint: disable=unused-argument
def test_update(bench_dvc, tmp_dir, dvc, make_dataset):
dataset = make_dataset(
cache=False, files=True, dvcfile=False, commit=False, remote=False
)
dvc.imp_url(str(dataset), "new")
(dataset / "new").write_text("new")
bench_dvc("update", "new.dvc")
bench_dvc("update", "new.dvc", name="noop")
@@ -0,0 +1,57 @@
"""
Tests for modifications to an existing dataset.
"""
import glob
import os
import random
import shutil
import sys
import pytest
# pylint: disable=unused-argument,unexpected-keyword-arg
@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires 3.10 glob.glob")
def test_partial_add(bench_dvc, tmp_dir, dvc, dataset, remote):
random.seed(4231)
# Move some files to create a partial dataset
os.makedirs("partial-copy")
for f in glob.glob("*", root_dir=dataset, recursive=True): # type: ignore[call-arg]
if random.random() > 0.5: # noqa: S311 # nosec
shutil.move(dataset / f, tmp_dir / "partial-copy" / f)
# Add/push partial dataset
dvc.add(str(dataset))
dvc.push()
# Add more files to the dataset
shutil.copytree("partial-copy", dataset, dirs_exist_ok=True)
# Benchmark operations for adding files to a dataset
bench_dvc("add", dataset, name="partial-add")
bench_dvc("push", name="partial-add")
bench_dvc("gc", "-f", "-w", name="noop")
bench_dvc("gc", "-f", "-w", "-c", name="cloud-noop")
@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires 3.10 glob.glob")
def test_partial_remove(bench_dvc, tmp_dir, dvc, dataset, remote):
random.seed(5232)
# Add/push full dataset
dvc.add(str(dataset))
dvc.push()
# Remove some files
for f in glob.glob("*", root_dir=dataset, recursive=True): # type: ignore[call-arg]
if random.random() > 0.5: # noqa: S311 # nosec
if os.path.isfile(dataset / f):
os.remove(dataset / f)
elif os.path.isdir(dataset / f):
shutil.rmtree(dataset / f)
# Benchmark operations for removing files from dataset
bench_dvc("add", dataset)
bench_dvc("push")
bench_dvc("gc", "-f", "-w")
bench_dvc("gc", "-f", "-w", "-c", name="cloud")

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