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