mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 15:28:30 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4762c38ff7 | ||
|
|
66acbc892a | ||
|
|
d2edbae7e3 | ||
|
|
be0c827b95 | ||
|
|
d6ecef7169 | ||
|
|
e8b649a75f | ||
|
|
8767f42f39 | ||
|
|
8580e66d60 | ||
|
|
c24ae2b0c3 | ||
|
|
4e17279440 | ||
|
|
50cdf4f213 | ||
|
|
f628f670c9 | ||
|
|
d7489bb65f | ||
|
|
5a4f4d3a8e | ||
|
|
2c24b24562 | ||
|
|
df9f76c2c0 | ||
|
|
0db3ece060 | ||
|
|
7c437cc349 | ||
|
|
3ed5efceb7 | ||
|
|
d33df60e41 | ||
|
|
c5415342d0 | ||
|
|
dd86076ba6 | ||
|
|
7dc3859400 | ||
|
|
4f3b28e268 | ||
|
|
32f435c47f | ||
|
|
054feae778 | ||
|
|
2abe515391 | ||
|
|
1eb89a2dc0 | ||
|
|
9c300a0a1c | ||
|
|
7b68a6f0ca | ||
|
|
ee823a59b9 | ||
|
|
16f404b16a | ||
|
|
0eda4bd91c | ||
|
|
a098a50c8d | ||
|
|
f57a378c0c | ||
|
|
3e0a50e52e | ||
|
|
ec72866eb9 | ||
|
|
e53d390ff9 | ||
|
|
dd330ac592 | ||
|
|
4ccb712b6d | ||
|
|
ecee2d3522 | ||
|
|
20bb6ddf3c | ||
|
|
e58fcd864b | ||
|
|
0514db3bfa | ||
|
|
1e0b2d7af0 | ||
|
|
bfcc8f877c | ||
|
|
46ad8745d3 | ||
|
|
eb13d3ca83 | ||
|
|
f46beb4958 | ||
|
|
9a1d74048a | ||
|
|
cce6aed378 | ||
|
|
612696cf4a | ||
|
|
3f5f983f5f | ||
|
|
fed9f088ca | ||
|
|
05eaeef012 | ||
|
|
48c0527755 | ||
|
|
4668a35f64 | ||
|
|
8107ec849b | ||
|
|
19284b3b4c | ||
|
|
315d1cde99 | ||
|
|
1d415efd0b | ||
|
|
e7c20aa1d8 | ||
|
|
058377f33e | ||
|
|
c7b1b14905 | ||
|
|
fbd8f5c185 | ||
|
|
7fe370b729 | ||
|
|
f9df6d2a16 | ||
|
|
2e4fb376da | ||
|
|
6d6a3f17b1 | ||
|
|
bdaf4aced6 | ||
|
|
0bf1802d4b | ||
|
|
6d3045320b | ||
|
|
3469dda8b8 | ||
|
|
eefede92b2 | ||
|
|
e295434ec6 | ||
|
|
b1f1b01ffc | ||
|
|
f41d7effdb | ||
|
|
6a417885b8 | ||
|
|
4221d9f70b | ||
|
|
5822d53687 | ||
|
|
a478a0ea98 | ||
|
|
e81c88e501 | ||
|
|
b286c311f4 | ||
|
|
d39c0ce618 | ||
|
|
1a8680de7d | ||
|
|
696c704632 | ||
|
|
3e68cd7105 | ||
|
|
73088fc7b9 | ||
|
|
978d5ac227 | ||
|
|
21585d18ab | ||
|
|
198cdeec06 | ||
|
|
e049c4bab1 | ||
|
|
e2ed671cc9 | ||
|
|
8a97b21b76 | ||
|
|
a113097817 | ||
|
|
fbcc3f389c | ||
|
|
7521e91d05 | ||
|
|
18d147e2f8 | ||
|
|
39d5d36033 | ||
|
|
091c59e663 | ||
|
|
f3010d1d65 | ||
|
|
36a2bf472b | ||
|
|
93a200cf5f | ||
|
|
dae08255d0 | ||
|
|
237e9d4354 | ||
|
|
788b4d3bb5 | ||
|
|
40c61c4f62 | ||
|
|
1a1294848b | ||
|
|
93dab26313 | ||
|
|
049a856cd9 | ||
|
|
eb6562207b | ||
|
|
5c8a642500 | ||
|
|
d2d6c730c4 | ||
|
|
3a100382bc | ||
|
|
61fe8bc59c | ||
|
|
eaac8d08a6 | ||
|
|
3522318dc9 | ||
|
|
4bd6d9f172 | ||
|
|
ef017befbe | ||
|
|
fa31fd1ef4 | ||
|
|
5592330769 | ||
|
|
ff6e88296d | ||
|
|
2d9f36176a | ||
|
|
31479f02ab | ||
|
|
b4e0c75a30 | ||
|
|
378486dbf5 | ||
|
|
6106b1341b | ||
|
|
73c493500b | ||
|
|
820d89606c | ||
|
|
2b63cedfcd | ||
|
|
1744028e09 | ||
|
|
82db0867fe | ||
|
|
6b582bd4d9 | ||
|
|
f9bb38b96f | ||
|
|
f7a82f6fc0 | ||
|
|
cbd49365c6 | ||
|
|
c944d4af5f | ||
|
|
0db0128d28 | ||
|
|
b8b6ace687 | ||
|
|
348dfc0496 | ||
|
|
db93709f21 | ||
|
|
23d6697610 | ||
|
|
4cc06339c8 | ||
|
|
b99cd0be11 | ||
|
|
0578643f08 | ||
|
|
2778c05e49 | ||
|
|
6287b13ba3 | ||
|
|
a69edcc2c8 | ||
|
|
64bf633b0f | ||
|
|
e5cf9b203d | ||
|
|
9fc4c60749 | ||
|
|
b5fe9b4596 | ||
|
|
03e692b4c2 | ||
|
|
d06a96720f | ||
|
|
ea2a38a207 | ||
|
|
5b9bdac965 | ||
|
|
5d7476a539 | ||
|
|
341ab4fe9b | ||
|
|
37717a7ef3 | ||
|
|
4ee98299ee | ||
|
|
4382b80d3b | ||
|
|
95ba68d181 | ||
|
|
ab08db5065 | ||
|
|
73811f67d7 | ||
|
|
60d4b4791b | ||
|
|
ceb0964fcf | ||
|
|
b13aec0f2b | ||
|
|
13d1276a12 | ||
|
|
7da20a7c74 | ||
|
|
a7175501aa | ||
|
|
57899ee86d | ||
|
|
4b9a4114fe | ||
|
|
82e559a51a | ||
|
|
33c6a9b19b | ||
|
|
14cc748139 | ||
|
|
40abf34c8c | ||
|
|
9fef38e778 | ||
|
|
b44e1c3646 | ||
|
|
c9b3d9dbae | ||
|
|
c2bf0dc713 | ||
|
|
451bd3ebc8 | ||
|
|
41bc81910f | ||
|
|
0aec0e3249 | ||
|
|
8406e605ab | ||
|
|
1bb7f39e14 | ||
|
|
a285d2ba47 |
+1
-1
@@ -3,7 +3,7 @@ coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 85
|
||||
target: 90
|
||||
threshold: 10%
|
||||
patch:
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: benchmarks
|
||||
on: [pull_request, workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
bench:
|
||||
name: run benchmarks
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-18.04]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.7
|
||||
- uses: iterative/dvc-bench@master
|
||||
with:
|
||||
pytest_options: "-k 'test_init or test_help'"
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
- master
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
deb:
|
||||
runs-on: ubuntu-18.04
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request: {}
|
||||
push: {}
|
||||
schedule:
|
||||
- cron: '5 1 * * *' # every day at 01:05
|
||||
|
||||
@@ -17,8 +13,13 @@ env:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/[email protected]
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/[email protected]
|
||||
@@ -27,7 +28,7 @@ jobs:
|
||||
- name: Install requirements
|
||||
run: |
|
||||
pip install wheel
|
||||
pip install ".[all,tests]" Pygments collective.checkdocs pre-commit
|
||||
pip install ".[all,tests,terraform]" Pygments collective.checkdocs pre-commit
|
||||
- name: install temporary dependencies
|
||||
run: |
|
||||
pip install git+https://github.com/isidentical/gdrivefs@service-account
|
||||
@@ -36,20 +37,17 @@ jobs:
|
||||
run: python setup.py checkdocs
|
||||
- uses: pre-commit/[email protected]
|
||||
tests:
|
||||
timeout-minutes: 45
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-18.04, windows-2019, macos-10.15]
|
||||
pyv: ["3.6", "3.7", "3.8", "3.9"]
|
||||
# macos 11.0 workers take too long to start up
|
||||
# include:
|
||||
# - os: "macos-11.0"
|
||||
# pyv: "3.9"
|
||||
os: [ubuntu-20.04, windows-latest, macos-latest]
|
||||
pyv: ["3.6", "3.7", "3.8", "3.9", "3.10"]
|
||||
exclude:
|
||||
- os: macos-10.15
|
||||
pyv: "3.9"
|
||||
|
||||
# no wheels for pygit2 yet
|
||||
- os: windows-latest
|
||||
pyv: "3.10"
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python
|
||||
@@ -65,14 +63,14 @@ jobs:
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('setup.cfg') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: install
|
||||
if: steps.cache.pip-cache-dir.cache-hit != 'true'
|
||||
run: |
|
||||
pip install wheel
|
||||
pip install -e ".[all,tests]"
|
||||
pip install --upgrade pip setuptools wheel
|
||||
pip install -e ".[all,tests,terraform]"
|
||||
- name: install temporary dependencies
|
||||
run: |
|
||||
pip install git+https://github.com/isidentical/gdrivefs@service-account
|
||||
@@ -96,6 +94,7 @@ jobs:
|
||||
run: |
|
||||
echo "extra_test_args=--disable-gdrive" >> $GITHUB_ENV
|
||||
- name: run tests
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
GDRIVE_CREDENTIALS_DATA: ${{ secrets.GDRIVE_CREDENTIALS_DATA }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -108,7 +107,7 @@ jobs:
|
||||
--cov-report=xml --cov-report=term
|
||||
--all ${{ env.extra_test_args }}
|
||||
- name: upload coverage report
|
||||
uses: codecov/codecov-action@v2.0.2
|
||||
uses: codecov/codecov-action@v2.1.0
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -6,7 +6,7 @@ repos:
|
||||
- id: black
|
||||
language_version: python3
|
||||
repo: https://github.com/ambv/black
|
||||
rev: 21.7b0
|
||||
rev: 21.9b0
|
||||
- hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright {yyyy} {name of copyright owner}
|
||||
Copyright 2017-2021 Iterative, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
include fastentrypoints.py
|
||||
include LICENSE
|
||||
include test_requirements.txt
|
||||
|
||||
+50
-40
@@ -8,7 +8,7 @@
|
||||
• `Tutorial <https://dvc.org/doc/get-started>`_
|
||||
• `Mailing List <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_
|
||||
|
||||
|Release| |CI| |Maintainability| |Coverage| |Donate| |DOI|
|
||||
|CI| |Maintainability| |Coverage| |Donate| |DOI|
|
||||
|
||||
|PyPI| |Packages| |Brew| |Conda| |Choco| |Snap|
|
||||
|
||||
@@ -20,8 +20,9 @@ learning projects. Key features:
|
||||
#. Simple **command line** Git-like experience. Does not require installing and maintaining
|
||||
any databases. Does not depend on any proprietary online services.
|
||||
|
||||
#. Management and versioning of **datasets** and **machine learning models**. Data is saved in
|
||||
S3, Google cloud, Azure, Alibaba cloud, SSH server, HDFS, or even local HDD RAID.
|
||||
#. Management and versioning of **datasets** and **machine learning
|
||||
models**. Data can be saved in S3, Google cloud, Azure, Alibaba
|
||||
cloud, SSH server, HDFS, or even local HDD RAID.
|
||||
|
||||
#. Makes projects **reproducible** and **shareable**; helping to answer questions about how
|
||||
a model was built.
|
||||
@@ -65,25 +66,25 @@ Quick start
|
||||
|
||||
Please read `Get Started <https://dvc.org/doc/get-started>`_ guide for a full version. Common workflow commands include:
|
||||
|
||||
+-----------------------------------+-------------------------------------------------------------------+
|
||||
| Step | Command |
|
||||
+===================================+===================================================================+
|
||||
| Track data | | ``$ git add train.py`` |
|
||||
| | | ``$ dvc add images.zip`` |
|
||||
+-----------------------------------+-------------------------------------------------------------------+
|
||||
| Connect code and data by commands | | ``$ dvc run -d images.zip -o images/ unzip -q images.zip`` |
|
||||
| | | ``$ dvc run -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`` |
|
||||
+-----------------------------------+-------------------------------------------------------------------+
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
| 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`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
|
||||
Installation
|
||||
============
|
||||
@@ -182,30 +183,37 @@ Fedora / CentOS (rpm)
|
||||
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 and focuses on data scientist' experience while
|
||||
data engineering pipeline orchestrates multiple data projects and focuses on efficient execution. DVC project can be used
|
||||
from data pipeline as a single execution step. `AirFlow DVC <https://github.com/covid-genomics/airflow-dvc>`_ is an example
|
||||
of such integration.
|
||||
#. 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 any remote storage (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.
|
||||
#. `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).
|
||||
#. 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 of Git+DVC repositories with pipeline and metrics visualization and
|
||||
DVC-specific cloud storage.
|
||||
#. `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/>`_ - online service of DVC repositories visualisation from DVC team. Also,
|
||||
integrated with `CML (CI/CD for ML) <https://cml.dev/>`_ for training models in clouds and Kubernetes.
|
||||
#. `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
|
||||
@@ -214,7 +222,9 @@ Contributing
|
||||
|Maintainability| |Donate|
|
||||
|
||||
Contributions are welcome! Please see our `Contributing Guide <https://dvc.org/doc/user-guide/contributing/core>`_ for more
|
||||
details.
|
||||
details. Thanks to all our contributors!
|
||||
|
||||
|Contribs|
|
||||
|
||||
Mailing List
|
||||
============
|
||||
@@ -244,10 +254,6 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
|
||||
:target: https://dvc.org
|
||||
:alt: DVC logo
|
||||
|
||||
.. |Release| image:: https://img.shields.io/badge/release-ok-brightgreen
|
||||
:target: https://travis-ci.com/iterative/dvc/branches
|
||||
:alt: Release
|
||||
|
||||
.. |CI| image:: https://github.com/iterative/dvc/workflows/Tests/badge.svg?branch=master
|
||||
:target: https://github.com/iterative/dvc/actions
|
||||
:alt: GHA Tests
|
||||
@@ -295,3 +301,7 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
|
||||
.. |Flowchart| image:: https://dvc.org/img/flow.gif
|
||||
:target: https://dvc.org/img/flow.gif
|
||||
:alt: how_dvc_works
|
||||
|
||||
.. |Contribs| image:: https://contrib.rocks/image?repo=iterative/dvc
|
||||
:target: https://github.com/iterative/dvc/graphs/contributors
|
||||
:alt: Contributors
|
||||
|
||||
+40
-1
@@ -1,10 +1,39 @@
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from argparse import Namespace
|
||||
|
||||
|
||||
@contextmanager
|
||||
def yappi_profile(
|
||||
path: Union[Callable[[], str], str] = None, wall_clock: bool = True
|
||||
):
|
||||
try:
|
||||
import yappi # pylint: disable=import-error
|
||||
except ImportError:
|
||||
print("Failed to run profiler, yappi is not installed")
|
||||
yield
|
||||
return
|
||||
|
||||
yappi.set_clock_type("wall" if wall_clock else "cpu")
|
||||
|
||||
yappi.start()
|
||||
yield
|
||||
yappi.stop()
|
||||
|
||||
# pylint:disable=no-member
|
||||
if path:
|
||||
stats = yappi.get_func_stats()
|
||||
fpath = path() if callable(path) else path
|
||||
stats.save(fpath, "callgrind")
|
||||
else:
|
||||
yappi.get_func_stats().print_all()
|
||||
yappi.get_thread_stats().print_all()
|
||||
|
||||
yappi.clear_stats()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def instrument(html_output=False):
|
||||
"""Run a statistical profiler"""
|
||||
@@ -70,6 +99,13 @@ def debugtools(args: "Namespace" = None, **kwargs):
|
||||
stack.enter_context(profile(kw.get("cprofile_dump")))
|
||||
if kw.get("instrument") or kw.get("instrument_open"):
|
||||
stack.enter_context(instrument(kw.get("instrument_open", False)))
|
||||
if kw.get("yappi"):
|
||||
from datetime import datetime
|
||||
|
||||
output = "callgrind.dvc-{0:%Y%m%d}_{0:%H%M%S}.out"
|
||||
stack.enter_context(
|
||||
yappi_profile(path=lambda: output.format(datetime.now()))
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
@@ -79,6 +115,9 @@ def add_debugging_flags(parser):
|
||||
parser.add_argument(
|
||||
"--cprofile", action="store_true", default=False, help=SUPPRESS
|
||||
)
|
||||
parser.add_argument(
|
||||
"--yappi", action="store_true", default=False, help=SUPPRESS
|
||||
)
|
||||
parser.add_argument("--cprofile-dump", help=SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--pdb", action="store_true", default=False, help=SUPPRESS
|
||||
|
||||
+8
-3
@@ -2,6 +2,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .env import DVC_NO_ANALYTICS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -42,9 +44,12 @@ def is_enabled():
|
||||
if env2bool("DVC_TEST"):
|
||||
return False
|
||||
|
||||
enabled = to_bool(
|
||||
Config(validate=False).get("core", {}).get("analytics", "true")
|
||||
)
|
||||
enabled = not os.getenv(DVC_NO_ANALYTICS)
|
||||
if enabled:
|
||||
enabled = to_bool(
|
||||
Config(validate=False).get("core", {}).get("analytics", "true")
|
||||
)
|
||||
|
||||
logger.debug("Analytics is {}abled.".format("en" if enabled else "dis"))
|
||||
|
||||
return enabled
|
||||
|
||||
@@ -30,6 +30,7 @@ from .command import (
|
||||
install,
|
||||
live,
|
||||
ls,
|
||||
machine,
|
||||
metrics,
|
||||
move,
|
||||
params,
|
||||
@@ -87,6 +88,7 @@ COMMANDS = [
|
||||
experiments,
|
||||
check_ignore,
|
||||
live,
|
||||
machine,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -33,17 +33,12 @@ class CmdBase(ABC):
|
||||
|
||||
def __init__(self, args):
|
||||
from dvc.repo import Repo
|
||||
from dvc.updater import Updater
|
||||
|
||||
os.chdir(args.cd)
|
||||
|
||||
self.repo = Repo(uninitialized=self.UNINITIALIZED)
|
||||
self.config = self.repo.config
|
||||
self.args = args
|
||||
if self.repo.tmp_dir:
|
||||
hardlink_lock = self.config["core"].get("hardlink_lock", False)
|
||||
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
|
||||
updater.check()
|
||||
|
||||
def do_run(self):
|
||||
with self.repo:
|
||||
|
||||
+17
-4
@@ -66,7 +66,8 @@ class CmdConfig(CmdBaseNoRepo):
|
||||
)
|
||||
return 1
|
||||
|
||||
levels = [self.args.level] if self.args.level else self.config.LEVELS
|
||||
levels = self._get_appropriate_levels(self.args.level)
|
||||
|
||||
for level in levels:
|
||||
conf = self.config.read(level)
|
||||
prefix = self._config_file_prefix(
|
||||
@@ -81,9 +82,7 @@ class CmdConfig(CmdBaseNoRepo):
|
||||
def _get(self, remote, section, opt):
|
||||
from dvc.config import ConfigError
|
||||
|
||||
levels = (
|
||||
[self.args.level] if self.args.level else self.config.LEVELS[::-1]
|
||||
)
|
||||
levels = self._get_appropriate_levels(self.args.level)[::-1]
|
||||
|
||||
for level in levels:
|
||||
conf = self.config.read(level)
|
||||
@@ -136,6 +135,20 @@ class CmdConfig(CmdBaseNoRepo):
|
||||
f"option '{opt}' doesn't exist in {name} '{section}'"
|
||||
)
|
||||
|
||||
def _get_appropriate_levels(self, levels):
|
||||
if levels:
|
||||
self._validate_level_for_non_repo_operation(levels)
|
||||
return [levels]
|
||||
if self.config.dvc_dir is None:
|
||||
return self.config.SYSTEM_LEVELS
|
||||
return self.config.LEVELS
|
||||
|
||||
def _validate_level_for_non_repo_operation(self, level):
|
||||
from dvc.config import ConfigError
|
||||
|
||||
if self.config.dvc_dir is None and level in self.config.REPO_LEVELS:
|
||||
raise ConfigError("Not inside a DVC repo")
|
||||
|
||||
@staticmethod
|
||||
def _format_config(config, prefix=""):
|
||||
from dvc.utils.flatten import flatten
|
||||
|
||||
+2
-3
@@ -103,9 +103,8 @@ class CmdDAG(CmdBase):
|
||||
if self.args.dot:
|
||||
ui.write(_show_dot(G))
|
||||
else:
|
||||
from dvc.utils.pager import pager
|
||||
|
||||
pager(_show_ascii(G))
|
||||
with ui.pager():
|
||||
ui.write(_show_ascii(G))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ def add_parser(subparsers, _parent_parser):
|
||||
help="Show status of all stages in the specified directory.",
|
||||
)
|
||||
status_parser.add_argument(
|
||||
"--json",
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
|
||||
+8
-6
@@ -1,5 +1,4 @@
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -16,7 +15,7 @@ def _digest(checksum):
|
||||
return "{}..{}".format(checksum["old"][0:8], checksum["new"][0:8])
|
||||
|
||||
|
||||
def _show_md(diff, show_hash=False, hide_missing=False):
|
||||
def _show_markdown(diff, show_hash=False, hide_missing=False):
|
||||
headers = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
|
||||
rows = []
|
||||
statuses = ["added", "deleted", "renamed", "modified"]
|
||||
@@ -142,10 +141,10 @@ class CmdDiff(CmdBase):
|
||||
del entry["hash"]
|
||||
diff[key] = entries
|
||||
|
||||
if self.args.show_json:
|
||||
ui.write(json.dumps(diff))
|
||||
elif self.args.show_md:
|
||||
_show_md(diff, show_hash, hide_missing)
|
||||
if self.args.json:
|
||||
ui.write_json(diff)
|
||||
elif self.args.markdown:
|
||||
_show_markdown(diff, show_hash, hide_missing)
|
||||
elif diff:
|
||||
self._show_diff(diff, hide_missing)
|
||||
|
||||
@@ -188,6 +187,7 @@ def add_parser(subparsers, parent_parser):
|
||||
nargs="?",
|
||||
)
|
||||
diff_parser.add_argument(
|
||||
"--json",
|
||||
"--show-json",
|
||||
help="Format the output into a JSON",
|
||||
action="store_true",
|
||||
@@ -200,9 +200,11 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
)
|
||||
diff_parser.add_argument(
|
||||
"--md",
|
||||
"--show-md",
|
||||
help="Show tabulated output in the Markdown format (GFM).",
|
||||
action="store_true",
|
||||
dest="markdown",
|
||||
default=False,
|
||||
)
|
||||
diff_parser.add_argument(
|
||||
|
||||
+270
-62
@@ -5,9 +5,8 @@ from datetime import date, datetime
|
||||
from fnmatch import fnmatch
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, Optional
|
||||
|
||||
from funcy import lmap
|
||||
from funcy import compact, lmap
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.command.metrics import DEFAULT_PRECISION
|
||||
@@ -19,9 +18,8 @@ from dvc.utils.flatten import flatten
|
||||
from dvc.utils.serialize import encode_exception
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.text import Text
|
||||
|
||||
from dvc.compare import TabularData
|
||||
from dvc.ui import RichText
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -141,6 +139,8 @@ def _collect_rows(
|
||||
precision=DEFAULT_PRECISION,
|
||||
sort_by=None,
|
||||
sort_order=None,
|
||||
fill_value=FILL_VALUE,
|
||||
iso=False,
|
||||
):
|
||||
from dvc.scm.git import Git
|
||||
|
||||
@@ -161,8 +161,8 @@ def _collect_rows(
|
||||
elif exp.get("queued"):
|
||||
state = "Queued"
|
||||
else:
|
||||
state = FILL_VALUE
|
||||
executor = exp.get("executor", FILL_VALUE)
|
||||
state = fill_value
|
||||
executor = exp.get("executor", fill_value)
|
||||
is_baseline = rev == "baseline"
|
||||
|
||||
if is_baseline:
|
||||
@@ -202,12 +202,12 @@ def _collect_rows(
|
||||
exp_name,
|
||||
name_rev,
|
||||
typ,
|
||||
_format_time(exp.get("timestamp")),
|
||||
_format_time(exp.get("timestamp"), fill_value, iso),
|
||||
parent,
|
||||
state,
|
||||
executor,
|
||||
]
|
||||
fill_value = FILL_VALUE_ERRORED if results.get("error") else FILL_VALUE
|
||||
fill_value = FILL_VALUE_ERRORED if results.get("error") else fill_value
|
||||
_extend_row(
|
||||
row,
|
||||
metric_names,
|
||||
@@ -258,11 +258,12 @@ def _sort_column(sort_by, metric_names, param_names):
|
||||
def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
|
||||
def _sort(item):
|
||||
rev, exp = item
|
||||
tip = exp.get("checkpoint_tip")
|
||||
exp_data = exp.get("data", {})
|
||||
tip = exp_data.get("checkpoint_tip")
|
||||
if tip and tip != rev:
|
||||
# Sort checkpoint experiments by tip commit
|
||||
return _sort((tip, experiments[tip]))
|
||||
data = exp.get(typ, {}).get(sort_path, {})
|
||||
data = exp_data.get(typ, {}).get(sort_path, {}).get("data", {})
|
||||
val = flatten(data).get(sort_name)
|
||||
return val is None, val
|
||||
|
||||
@@ -274,19 +275,21 @@ def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
|
||||
return ret
|
||||
|
||||
|
||||
def _format_time(timestamp):
|
||||
if timestamp is None:
|
||||
return FILL_VALUE
|
||||
if timestamp.date() == date.today():
|
||||
def _format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
|
||||
if datetime_obj is None:
|
||||
return fill_value
|
||||
|
||||
if iso:
|
||||
return datetime_obj.isoformat()
|
||||
|
||||
if datetime_obj.date() == date.today():
|
||||
fmt = "%I:%M %p"
|
||||
else:
|
||||
fmt = "%b %d, %Y"
|
||||
return timestamp.strftime(fmt)
|
||||
return datetime_obj.strftime(fmt)
|
||||
|
||||
|
||||
def _extend_row(row, names, items, precision, fill_value=FILL_VALUE):
|
||||
from rich.text import Text
|
||||
|
||||
from dvc.compare import _format_field, with_value
|
||||
|
||||
if not items:
|
||||
@@ -301,10 +304,10 @@ def _extend_row(row, names, items, precision, fill_value=FILL_VALUE):
|
||||
item.get(name),
|
||||
FILL_VALUE_ERRORED if data.get("error", None) else fill_value,
|
||||
)
|
||||
# wrap field data in rich.Text, otherwise rich may
|
||||
# wrap field data in ui.rich_text, otherwise rich may
|
||||
# interpret unescaped braces from list/dict types as rich
|
||||
# markup tags
|
||||
row.append(Text(str(_format_field(value, precision))))
|
||||
row.append(ui.rich_text(str(_format_field(value, precision))))
|
||||
|
||||
|
||||
def _parse_filter_list(param_list):
|
||||
@@ -320,6 +323,7 @@ def _parse_filter_list(param_list):
|
||||
|
||||
def experiments_table(
|
||||
all_experiments,
|
||||
headers,
|
||||
metric_headers,
|
||||
metric_names,
|
||||
param_headers,
|
||||
@@ -327,22 +331,15 @@ def experiments_table(
|
||||
sort_by=None,
|
||||
sort_order=None,
|
||||
precision=DEFAULT_PRECISION,
|
||||
fill_value=FILL_VALUE,
|
||||
iso=False,
|
||||
) -> "TabularData":
|
||||
from funcy import lconcat
|
||||
|
||||
from dvc.compare import TabularData
|
||||
|
||||
headers = [
|
||||
"Experiment",
|
||||
"rev",
|
||||
"typ",
|
||||
"Created",
|
||||
"parent",
|
||||
"State",
|
||||
"Executor",
|
||||
]
|
||||
td = TabularData(
|
||||
lconcat(headers, metric_headers, param_headers), fill_value=FILL_VALUE
|
||||
lconcat(headers, metric_headers, param_headers), fill_value=fill_value
|
||||
)
|
||||
for base_rev, experiments in all_experiments.items():
|
||||
rows = _collect_rows(
|
||||
@@ -353,23 +350,23 @@ def experiments_table(
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
precision=precision,
|
||||
fill_value=fill_value,
|
||||
iso=iso,
|
||||
)
|
||||
td.extend(rows)
|
||||
|
||||
return td
|
||||
|
||||
|
||||
def prepare_exp_id(kwargs) -> "Text":
|
||||
from rich.text import Text
|
||||
|
||||
def prepare_exp_id(kwargs) -> "RichText":
|
||||
exp_name = kwargs["Experiment"]
|
||||
rev = kwargs["rev"]
|
||||
typ = kwargs.get("typ", "baseline")
|
||||
|
||||
if typ == "baseline" or not exp_name:
|
||||
text = Text(exp_name or rev)
|
||||
text = ui.rich_text(exp_name or rev)
|
||||
else:
|
||||
text = Text.assemble(rev, " [", (exp_name, "bold"), "]")
|
||||
text = ui.rich_text.assemble(rev, " [", (exp_name, "bold"), "]")
|
||||
|
||||
parent = kwargs.get("parent")
|
||||
suff = f" ({parent})" if parent else ""
|
||||
@@ -377,7 +374,7 @@ def prepare_exp_id(kwargs) -> "Text":
|
||||
|
||||
tree = experiment_types[typ]
|
||||
pref = f"{tree} " if tree else ""
|
||||
return Text(pref) + text
|
||||
return ui.rich_text(pref) + text
|
||||
|
||||
|
||||
def baseline_styler(typ):
|
||||
@@ -385,8 +382,10 @@ def baseline_styler(typ):
|
||||
|
||||
|
||||
def show_experiments(
|
||||
all_experiments, pager=True, no_timestamp=False, **kwargs
|
||||
all_experiments, pager=True, no_timestamp=False, csv=False, **kwargs
|
||||
):
|
||||
from funcy.seqs import flatten as flatten_list
|
||||
|
||||
include_metrics = _parse_filter_list(kwargs.pop("include_metrics", []))
|
||||
exclude_metrics = _parse_filter_list(kwargs.pop("exclude_metrics", []))
|
||||
include_params = _parse_filter_list(kwargs.pop("include_params", []))
|
||||
@@ -399,11 +398,26 @@ def show_experiments(
|
||||
include_params=include_params,
|
||||
exclude_params=exclude_params,
|
||||
)
|
||||
metric_headers = _normalize_headers(metric_names)
|
||||
param_headers = _normalize_headers(param_names)
|
||||
|
||||
headers = [
|
||||
"Experiment",
|
||||
"rev",
|
||||
"typ",
|
||||
"Created",
|
||||
"parent",
|
||||
"State",
|
||||
"Executor",
|
||||
]
|
||||
|
||||
names = {**metric_names, **param_names}
|
||||
counter = Counter(flatten_list([list(a.keys()) for a in names.values()]))
|
||||
counter.update(headers)
|
||||
metric_headers = _normalize_headers(metric_names, counter)
|
||||
param_headers = _normalize_headers(param_names, counter)
|
||||
|
||||
td = experiments_table(
|
||||
all_experiments,
|
||||
headers,
|
||||
metric_headers,
|
||||
metric_names,
|
||||
param_headers,
|
||||
@@ -411,6 +425,8 @@ def show_experiments(
|
||||
kwargs.get("sort_by"),
|
||||
kwargs.get("sort_order"),
|
||||
kwargs.get("precision"),
|
||||
kwargs.get("fill_value"),
|
||||
kwargs.get("iso"),
|
||||
)
|
||||
|
||||
if no_timestamp:
|
||||
@@ -422,9 +438,12 @@ def show_experiments(
|
||||
|
||||
row_styles = lmap(baseline_styler, td.column("typ"))
|
||||
|
||||
merge_headers = ["Experiment", "rev", "typ", "parent"]
|
||||
td.column("Experiment")[:] = map(prepare_exp_id, td.as_dict(merge_headers))
|
||||
td.drop(*merge_headers[1:])
|
||||
if not csv:
|
||||
merge_headers = ["Experiment", "rev", "typ", "parent"]
|
||||
td.column("Experiment")[:] = map(
|
||||
prepare_exp_id, td.as_dict(merge_headers)
|
||||
)
|
||||
td.drop(*merge_headers[1:])
|
||||
|
||||
headers = {"metrics": metric_headers, "params": param_headers}
|
||||
styles = {
|
||||
@@ -453,13 +472,11 @@ def show_experiments(
|
||||
rich_table=True,
|
||||
header_styles=styles,
|
||||
row_styles=row_styles,
|
||||
csv=csv,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_headers(names):
|
||||
count = Counter(
|
||||
name for path in names for name in names[path] for path in names
|
||||
)
|
||||
def _normalize_headers(names, count):
|
||||
return [
|
||||
name if count[name] == 1 else f"{path}:{name}"
|
||||
for path in names
|
||||
@@ -488,11 +505,17 @@ class CmdExperimentsShow(CmdBase):
|
||||
logger.exception("failed to show experiments")
|
||||
return 1
|
||||
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(all_experiments, default=_format_json))
|
||||
if self.args.json:
|
||||
ui.write_json(all_experiments, default=_format_json)
|
||||
else:
|
||||
precision = (
|
||||
self.args.precision or None
|
||||
if self.args.csv
|
||||
else DEFAULT_PRECISION
|
||||
)
|
||||
fill_value = "" if self.args.csv else FILL_VALUE
|
||||
iso = True if self.args.csv else False
|
||||
|
||||
show_experiments(
|
||||
all_experiments,
|
||||
include_metrics=self.args.include_metrics,
|
||||
@@ -502,8 +525,11 @@ class CmdExperimentsShow(CmdBase):
|
||||
no_timestamp=self.args.no_timestamp,
|
||||
sort_by=self.args.sort_by,
|
||||
sort_order=self.args.sort_order,
|
||||
precision=self.args.precision or DEFAULT_PRECISION,
|
||||
precision=precision,
|
||||
fill_value=fill_value,
|
||||
iso=iso,
|
||||
pager=not self.args.no_pager,
|
||||
csv=self.args.csv,
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -532,10 +558,8 @@ class CmdExperimentsDiff(CmdBase):
|
||||
logger.exception("failed to show experiments diff")
|
||||
return 1
|
||||
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(diff))
|
||||
if self.args.json:
|
||||
ui.write_json(diff)
|
||||
else:
|
||||
from dvc.compare import show_diff
|
||||
|
||||
@@ -550,7 +574,7 @@ class CmdExperimentsDiff(CmdBase):
|
||||
show_diff(
|
||||
diff[key],
|
||||
title=title,
|
||||
markdown=self.args.show_md,
|
||||
markdown=self.args.markdown,
|
||||
no_path=self.args.no_path,
|
||||
old=self.args.old,
|
||||
on_empty_diff="diff not supported",
|
||||
@@ -635,7 +659,7 @@ class CmdExperimentsGC(CmdRepro):
|
||||
logger.warning(msg)
|
||||
|
||||
msg = "Are you sure you want to proceed?"
|
||||
if not self.args.force and not prompt.confirm(msg):
|
||||
if not self.args.force and not ui.confirm(msg):
|
||||
return 1
|
||||
|
||||
removed = self.repo.experiments.gc(
|
||||
@@ -747,12 +771,86 @@ class CmdExperimentsRemove(CmdBase):
|
||||
def run(self):
|
||||
|
||||
self.repo.experiments.remove(
|
||||
exp_names=self.args.experiment, queue=self.args.queue
|
||||
exp_names=self.args.experiment,
|
||||
queue=self.args.queue,
|
||||
clear_all=self.args.all,
|
||||
remote=self.args.git_remote,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
class CmdExperimentsInit(CmdBase):
|
||||
CODE = "src"
|
||||
DATA = "data"
|
||||
MODELS = "models"
|
||||
DEFAULT_METRICS = "metrics.json"
|
||||
DEFAULT_PARAMS = "params.yaml"
|
||||
PLOTS = "plots"
|
||||
DVCLIVE = "dvclive"
|
||||
DEFAULTS = {
|
||||
"code": CODE,
|
||||
"data": DATA,
|
||||
"models": MODELS,
|
||||
"metrics": DEFAULT_METRICS,
|
||||
"params": DEFAULT_PARAMS,
|
||||
"plots": PLOTS,
|
||||
"live": DVCLIVE,
|
||||
}
|
||||
|
||||
def run(self):
|
||||
from dvc.command.stage import parse_cmd
|
||||
|
||||
cmd = parse_cmd(self.args.cmd)
|
||||
if not self.args.interactive and not cmd:
|
||||
raise InvalidArgumentError("command is not specified")
|
||||
|
||||
from dvc.repo.experiments.init import init
|
||||
|
||||
defaults = {}
|
||||
if not self.args.explicit:
|
||||
config = self.repo.config["exp"]
|
||||
defaults.update({**self.DEFAULTS, **config})
|
||||
|
||||
cli_args = compact(
|
||||
{
|
||||
"cmd": cmd,
|
||||
"code": self.args.code,
|
||||
"data": self.args.data,
|
||||
"models": self.args.models,
|
||||
"metrics": self.args.metrics,
|
||||
"params": self.args.params,
|
||||
"plots": self.args.plots,
|
||||
"live": self.args.live,
|
||||
}
|
||||
)
|
||||
|
||||
initialized_stage = init(
|
||||
self.repo,
|
||||
name=self.args.name,
|
||||
type=self.args.type,
|
||||
defaults=defaults,
|
||||
overrides=cli_args,
|
||||
interactive=self.args.interactive,
|
||||
force=self.args.force,
|
||||
)
|
||||
if self.args.run:
|
||||
return self.repo.experiments.run(
|
||||
targets=[initialized_stage.addressing]
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
class RawDefaultsHelpFormatter(
|
||||
argparse.RawDescriptionHelpFormatter,
|
||||
argparse.ArgumentDefaultsHelpFormatter,
|
||||
):
|
||||
def _get_help_string(self, action: argparse.Action) -> Optional[str]:
|
||||
if action.default:
|
||||
return super()._get_help_string(action)
|
||||
return action.help
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
EXPERIMENTS_HELP = "Commands to run and compare experiments."
|
||||
|
||||
@@ -875,11 +973,19 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Always show git commit SHAs instead of branch/tag names.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--json",
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Print output in JSON format instead of a human-readable table.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--csv",
|
||||
"--show-csv",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Print output in csv format instead of a human-readable table.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--precision",
|
||||
type=int,
|
||||
@@ -943,15 +1049,18 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Show only params that are stage dependencies.",
|
||||
)
|
||||
experiments_diff_parser.add_argument(
|
||||
"--json",
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show output in JSON format.",
|
||||
)
|
||||
experiments_diff_parser.add_argument(
|
||||
"--md",
|
||||
"--show-md",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="markdown",
|
||||
help="Show tabulated output in the Markdown format (GFM).",
|
||||
)
|
||||
experiments_diff_parser.add_argument(
|
||||
@@ -1109,9 +1218,9 @@ def add_parser(subparsers, parent_parser):
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=(
|
||||
"Optional Git remote name or Git URL. If provided, experiments "
|
||||
"from the specified Git repository will be listed instead of "
|
||||
"local experiments."
|
||||
"Optional Git remote name or Git URL. "
|
||||
"If provided, experiments from the specified Git repository "
|
||||
" will be listed instead of local ones."
|
||||
),
|
||||
metavar="[<git_remote>]",
|
||||
)
|
||||
@@ -1229,7 +1338,7 @@ def add_parser(subparsers, parent_parser):
|
||||
)
|
||||
experiments_pull_parser.set_defaults(func=CmdExperimentsPull)
|
||||
|
||||
EXPERIMENTS_REMOVE_HELP = "Remove local experiments."
|
||||
EXPERIMENTS_REMOVE_HELP = "Remove experiments."
|
||||
experiments_remove_parser = experiments_subparsers.add_parser(
|
||||
"remove",
|
||||
parents=[parent_parser],
|
||||
@@ -1237,9 +1346,22 @@ def add_parser(subparsers, parent_parser):
|
||||
help=EXPERIMENTS_REMOVE_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
experiments_remove_parser.add_argument(
|
||||
remove_group = experiments_remove_parser.add_mutually_exclusive_group()
|
||||
remove_group.add_argument(
|
||||
"--queue", action="store_true", help="Remove all queued experiments."
|
||||
)
|
||||
remove_group.add_argument(
|
||||
"-A",
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Remove all committed experiments.",
|
||||
)
|
||||
remove_group.add_argument(
|
||||
"-g",
|
||||
"--git-remote",
|
||||
metavar="<git_remote>",
|
||||
help="Name or URL of the Git remote to remove the experiment from",
|
||||
)
|
||||
experiments_remove_parser.add_argument(
|
||||
"experiment",
|
||||
nargs="*",
|
||||
@@ -1248,6 +1370,92 @@ def add_parser(subparsers, parent_parser):
|
||||
)
|
||||
experiments_remove_parser.set_defaults(func=CmdExperimentsRemove)
|
||||
|
||||
EXPERIMENTS_INIT_HELP = "Initialize experiments."
|
||||
experiments_init_parser = experiments_subparsers.add_parser(
|
||||
"init",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(EXPERIMENTS_INIT_HELP, "exp/init"),
|
||||
formatter_class=RawDefaultsHelpFormatter,
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"cmd",
|
||||
nargs=argparse.REMAINDER,
|
||||
help="Command to execute.",
|
||||
metavar="command",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--run",
|
||||
action="store_true",
|
||||
help="Run the experiment after initializing it",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--interactive",
|
||||
"-i",
|
||||
action="store_true",
|
||||
help="Prompt for values that are not provided",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Overwrite existing stage",
|
||||
)
|
||||
|
||||
experiments_init_parser.add_argument(
|
||||
"--explicit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only use the path values explicitly provided",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--name", "-n", help="Name of the stage to create"
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--code",
|
||||
default=CmdExperimentsInit.CODE,
|
||||
help="Path to the source file or directory "
|
||||
"which your experiments depend",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--data",
|
||||
default=CmdExperimentsInit.DATA,
|
||||
help="Path to the data file or directory "
|
||||
"which your experiments depend",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--models",
|
||||
default=CmdExperimentsInit.MODELS,
|
||||
help="Path to the model file or directory for your experiments",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--params",
|
||||
default=CmdExperimentsInit.DEFAULT_PARAMS,
|
||||
help="Path to the parameters file for your experiments",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--metrics",
|
||||
default=CmdExperimentsInit.DEFAULT_METRICS,
|
||||
help="Path to the metrics file for your experiments",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--plots",
|
||||
default=CmdExperimentsInit.PLOTS,
|
||||
help="Path to the plots file or directory for your experiments",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--live",
|
||||
help="Path to log dvclive outputs for your experiments "
|
||||
f"(default: {CmdExperimentsInit.DVCLIVE})",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--type",
|
||||
choices=["default", "live"],
|
||||
default="default",
|
||||
help="Select type of stage to create",
|
||||
)
|
||||
experiments_init_parser.set_defaults(func=CmdExperimentsInit)
|
||||
|
||||
|
||||
def _add_run_common(parser):
|
||||
"""Add common args for 'exp run' and 'exp resume'."""
|
||||
|
||||
+5
-4
@@ -1,5 +1,5 @@
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, fix_subparsers
|
||||
@@ -13,10 +13,11 @@ class CmdLive(CmdBase):
|
||||
metrics, plots = self.repo.live.show(target=target, revs=revs)
|
||||
|
||||
if plots:
|
||||
html_path = self.args.target + ".html"
|
||||
self.repo.plots.write_html(html_path, plots, metrics)
|
||||
html_path = Path.cwd() / (self.args.target + "_html")
|
||||
from dvc.render.utils import render
|
||||
|
||||
ui.write("\nfile://", os.path.abspath(html_path), sep="")
|
||||
index_path = render(self.repo, plots, metrics, html_path)
|
||||
ui.write(index_path.as_uri())
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
@@ -34,10 +34,8 @@ class CmdList(CmdBaseNoRepo):
|
||||
recursive=self.args.recursive,
|
||||
dvc_only=self.args.dvc_only,
|
||||
)
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(entries))
|
||||
if self.args.json:
|
||||
ui.write_json(entries)
|
||||
elif entries:
|
||||
entries = _prettify(entries, with_color=True)
|
||||
ui.write("\n".join(entries))
|
||||
@@ -54,6 +52,7 @@ def add_parser(subparsers, parent_parser):
|
||||
)
|
||||
list_parser = subparsers.add_parser(
|
||||
"list",
|
||||
aliases=["ls"],
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(LIST_HELP, "list"),
|
||||
help=LIST_HELP,
|
||||
@@ -70,7 +69,10 @@ def add_parser(subparsers, parent_parser):
|
||||
"--dvc-only", action="store_true", help="Show only DVC outputs."
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--show-json", action="store_true", help="Show output in JSON format."
|
||||
"--json",
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
help="Show output in JSON format.",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--rev",
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import argparse
|
||||
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.command.config import CmdConfig
|
||||
from dvc.config import ConfigError
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import format_link
|
||||
|
||||
|
||||
class MachineDisabledError(ConfigError):
|
||||
def __init__(self):
|
||||
super().__init__("Machine feature is disabled")
|
||||
|
||||
|
||||
class CmdMachineConfig(CmdConfig):
|
||||
def __init__(self, args):
|
||||
|
||||
super().__init__(args)
|
||||
if not self.config["feature"].get("machine", False):
|
||||
raise MachineDisabledError
|
||||
|
||||
if getattr(self.args, "name", None):
|
||||
self.args.name = self.args.name.lower()
|
||||
|
||||
def _check_exists(self, conf):
|
||||
if self.args.name not in conf["machine"]:
|
||||
raise ConfigError(f"machine '{self.args.name}' doesn't exist.")
|
||||
|
||||
|
||||
class CmdMachineAdd(CmdMachineConfig):
|
||||
def run(self):
|
||||
from dvc.machine import validate_name
|
||||
|
||||
validate_name(self.args.name)
|
||||
|
||||
if self.args.default:
|
||||
ui.write(f"Setting '{self.args.name}' as a default machine.")
|
||||
|
||||
with self.config.edit(self.args.level) as conf:
|
||||
if self.args.name in conf["machine"] and not self.args.force:
|
||||
raise ConfigError(
|
||||
"machine '{}' already exists. Use `-f|--force` to "
|
||||
"overwrite it.".format(self.args.name)
|
||||
)
|
||||
|
||||
conf["machine"][self.args.name] = {"cloud": self.args.cloud}
|
||||
if self.args.default:
|
||||
conf["core"]["machine"] = self.args.name
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
class CmdMachineRemove(CmdMachineConfig):
|
||||
def run(self):
|
||||
with self.config.edit(self.args.level) as conf:
|
||||
self._check_exists(conf)
|
||||
del conf["machine"][self.args.name]
|
||||
|
||||
up_to_level = self.args.level or "repo"
|
||||
# Remove core.machine refs to this machine in any shadowing configs
|
||||
for level in reversed(self.config.LEVELS):
|
||||
with self.config.edit(level) as conf:
|
||||
if conf["core"].get("machine") == self.args.name:
|
||||
del conf["core"]["machine"]
|
||||
|
||||
if level == up_to_level:
|
||||
break
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
class CmdMachineList(CmdMachineConfig):
|
||||
def run(self):
|
||||
levels = [self.args.level] if self.args.level else self.config.LEVELS
|
||||
for level in levels:
|
||||
conf = self.config.read(level)["machine"]
|
||||
if self.args.name:
|
||||
conf = conf.get(self.args.name, {})
|
||||
prefix = self._config_file_prefix(
|
||||
self.args.show_origin, self.config, level
|
||||
)
|
||||
configs = list(self._format_config(conf, prefix))
|
||||
if configs:
|
||||
ui.write("\n".join(configs))
|
||||
return 0
|
||||
|
||||
|
||||
class CmdMachineModify(CmdMachineConfig):
|
||||
def run(self):
|
||||
from dvc.config import merge
|
||||
|
||||
with self.config.edit(self.args.level) as conf:
|
||||
merged = self.config.load_config_to_level(self.args.level)
|
||||
merge(merged, conf)
|
||||
self._check_exists(merged)
|
||||
|
||||
if self.args.name not in conf["machine"]:
|
||||
conf["machine"][self.args.name] = {}
|
||||
section = conf["machine"][self.args.name]
|
||||
if self.args.unset:
|
||||
section.pop(self.args.option, None)
|
||||
else:
|
||||
section[self.args.option] = self.args.value
|
||||
return 0
|
||||
|
||||
|
||||
class CmdMachineDefault(CmdMachineConfig):
|
||||
def run(self):
|
||||
if self.args.name is None and not self.args.unset:
|
||||
conf = self.config.read(self.args.level)
|
||||
try:
|
||||
print(conf["core"]["machine"])
|
||||
except KeyError:
|
||||
ui.write("No default machine set")
|
||||
return 1
|
||||
else:
|
||||
with self.config.edit(self.args.level) as conf:
|
||||
if self.args.unset:
|
||||
conf["core"].pop("machine", None)
|
||||
else:
|
||||
merged_conf = self.config.load_config_to_level(
|
||||
self.args.level
|
||||
)
|
||||
if (
|
||||
self.args.name in conf["machine"]
|
||||
or self.args.name in merged_conf["machine"]
|
||||
):
|
||||
conf["core"]["machine"] = self.args.name
|
||||
else:
|
||||
raise ConfigError(
|
||||
"default machine must be present in machine "
|
||||
"list."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
class CmdMachineCreate(CmdBase):
|
||||
def run(self):
|
||||
if self.repo.machine is None:
|
||||
raise MachineDisabledError
|
||||
|
||||
self.repo.machine.create(self.args.name)
|
||||
return 0
|
||||
|
||||
|
||||
class CmdMachineDestroy(CmdBase):
|
||||
def run(self):
|
||||
if self.repo.machine is None:
|
||||
raise MachineDisabledError
|
||||
|
||||
self.repo.machine.destroy(self.args.name)
|
||||
return 0
|
||||
|
||||
|
||||
class CmdMachineSsh(CmdBase):
|
||||
def run(self):
|
||||
if self.repo.machine is None:
|
||||
raise MachineDisabledError
|
||||
|
||||
self.repo.machine.run_shell(self.args.name)
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
from dvc.command.config import parent_config_parser
|
||||
|
||||
machine_HELP = "Set up and manage cloud machines."
|
||||
machine_parser = subparsers.add_parser(
|
||||
"machine",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(machine_HELP, "machine"),
|
||||
# NOTE: suppress help during development to hide command
|
||||
# help=machine_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
machine_subparsers = machine_parser.add_subparsers(
|
||||
dest="cmd",
|
||||
help="Use `dvc machine CMD --help` for " "command-specific help.",
|
||||
)
|
||||
|
||||
fix_subparsers(machine_subparsers)
|
||||
|
||||
machine_ADD_HELP = "Add a new data machine."
|
||||
machine_add_parser = machine_subparsers.add_parser(
|
||||
"add",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_ADD_HELP, "machine/add"),
|
||||
help=machine_ADD_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_add_parser.add_argument("name", help="Name of the machine")
|
||||
machine_add_parser.add_argument(
|
||||
"cloud",
|
||||
help="Machine cloud. See full list of supported clouds at {}".format(
|
||||
format_link(
|
||||
"https://github.com/iterative/"
|
||||
"terraform-provider-iterative#machine"
|
||||
)
|
||||
),
|
||||
)
|
||||
machine_add_parser.add_argument(
|
||||
"-d",
|
||||
"--default",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Set as default machine.",
|
||||
)
|
||||
machine_add_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force overwriting existing configs",
|
||||
)
|
||||
machine_add_parser.set_defaults(func=CmdMachineAdd)
|
||||
|
||||
machine_DEFAULT_HELP = "Set/unset the default machine."
|
||||
machine_default_parser = machine_subparsers.add_parser(
|
||||
"default",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_DEFAULT_HELP, "machine/default"),
|
||||
help=machine_DEFAULT_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_default_parser.add_argument(
|
||||
"name", nargs="?", help="Name of the machine"
|
||||
)
|
||||
machine_default_parser.add_argument(
|
||||
"-u",
|
||||
"--unset",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Unset default machine.",
|
||||
)
|
||||
machine_default_parser.set_defaults(func=CmdMachineDefault)
|
||||
|
||||
machine_LIST_HELP = "List the configuration of one/all machines."
|
||||
machine_list_parser = machine_subparsers.add_parser(
|
||||
"list",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_LIST_HELP, "machine/list"),
|
||||
help=machine_LIST_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_list_parser.add_argument(
|
||||
"--show-origin",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="Show the source file containing each config value.",
|
||||
)
|
||||
machine_list_parser.add_argument(
|
||||
"name",
|
||||
nargs="?",
|
||||
type=str,
|
||||
help="name of machine to specify",
|
||||
)
|
||||
machine_list_parser.set_defaults(func=CmdMachineList)
|
||||
machine_MODIFY_HELP = "Modify the configuration of an machine."
|
||||
machine_modify_parser = machine_subparsers.add_parser(
|
||||
"modify",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_MODIFY_HELP, "machine/modify"),
|
||||
help=machine_MODIFY_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_modify_parser.add_argument("name", help="Name of the machine")
|
||||
machine_modify_parser.add_argument(
|
||||
"option", help="Name of the option to modify."
|
||||
)
|
||||
machine_modify_parser.add_argument(
|
||||
"value", nargs="?", help="(optional) Value of the option."
|
||||
)
|
||||
machine_modify_parser.add_argument(
|
||||
"-u",
|
||||
"--unset",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="Unset option.",
|
||||
)
|
||||
machine_modify_parser.set_defaults(func=CmdMachineModify)
|
||||
|
||||
machine_REMOVE_HELP = "Remove an machine."
|
||||
machine_remove_parser = machine_subparsers.add_parser(
|
||||
"remove",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_REMOVE_HELP, "machine/remove"),
|
||||
help=machine_REMOVE_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_remove_parser.add_argument(
|
||||
"name", help="Name of the machine to remove."
|
||||
)
|
||||
machine_remove_parser.set_defaults(func=CmdMachineRemove)
|
||||
|
||||
machine_CREATE_HELP = "Create and start a machine instance."
|
||||
machine_create_parser = machine_subparsers.add_parser(
|
||||
"create",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_CREATE_HELP, "machine/create"),
|
||||
help=machine_CREATE_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_create_parser.add_argument(
|
||||
"name", help="Name of the machine to create."
|
||||
)
|
||||
machine_create_parser.set_defaults(func=CmdMachineCreate)
|
||||
|
||||
machine_DESTROY_HELP = "Destroy an machine instance."
|
||||
machine_destroy_parser = machine_subparsers.add_parser(
|
||||
"destroy",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_DESTROY_HELP, "machine/destroy"),
|
||||
help=machine_DESTROY_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_destroy_parser.add_argument(
|
||||
"name", help="Name of the machine instance to destroy."
|
||||
)
|
||||
machine_destroy_parser.set_defaults(func=CmdMachineDestroy)
|
||||
|
||||
machine_SSH_HELP = "Connect to a machine via SSH."
|
||||
machine_ssh_parser = machine_subparsers.add_parser(
|
||||
"ssh",
|
||||
parents=[parent_config_parser, parent_parser],
|
||||
description=append_doc_link(machine_SSH_HELP, "machine/ssh"),
|
||||
help=machine_SSH_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
machine_ssh_parser.add_argument(
|
||||
"name", help="Name of the machine instance to connect to."
|
||||
)
|
||||
machine_ssh_parser.set_defaults(func=CmdMachineSsh)
|
||||
+12
-10
@@ -31,16 +31,14 @@ class CmdMetricsShow(CmdMetricsBase):
|
||||
logger.exception("")
|
||||
return 1
|
||||
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(metrics, default=encode_exception))
|
||||
if self.args.json:
|
||||
ui.write_json(metrics, default=encode_exception)
|
||||
else:
|
||||
from dvc.compare import show_metrics
|
||||
|
||||
show_metrics(
|
||||
metrics,
|
||||
markdown=self.args.show_md,
|
||||
markdown=self.args.markdown,
|
||||
all_branches=self.args.all_branches,
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
@@ -65,17 +63,15 @@ class CmdMetricsDiff(CmdMetricsBase):
|
||||
logger.exception("failed to show metrics diff")
|
||||
return 1
|
||||
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(diff))
|
||||
if self.args.json:
|
||||
ui.write_json(diff)
|
||||
else:
|
||||
from dvc.compare import show_diff
|
||||
|
||||
show_diff(
|
||||
diff,
|
||||
title="Metric",
|
||||
markdown=self.args.show_md,
|
||||
markdown=self.args.markdown,
|
||||
no_path=self.args.no_path,
|
||||
precision=self.args.precision or DEFAULT_PRECISION,
|
||||
round_digits=True,
|
||||
@@ -140,15 +136,18 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Show metrics for all commits.",
|
||||
)
|
||||
metrics_show_parser.add_argument(
|
||||
"--json",
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show output in JSON format.",
|
||||
)
|
||||
metrics_show_parser.add_argument(
|
||||
"--md",
|
||||
"--show-md",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="markdown",
|
||||
help="Show tabulated output in the Markdown format (GFM).",
|
||||
)
|
||||
metrics_show_parser.add_argument(
|
||||
@@ -220,15 +219,18 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Show unchanged metrics as well.",
|
||||
)
|
||||
metrics_diff_parser.add_argument(
|
||||
"--json",
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show output in JSON format.",
|
||||
)
|
||||
metrics_diff_parser.add_argument(
|
||||
"--md",
|
||||
"--show-md",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="markdown",
|
||||
help="Show tabulated output in the Markdown format (GFM).",
|
||||
)
|
||||
metrics_diff_parser.add_argument(
|
||||
|
||||
@@ -25,17 +25,15 @@ class CmdParamsDiff(CmdBase):
|
||||
logger.exception("failed to show params diff")
|
||||
return 1
|
||||
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(diff))
|
||||
if self.args.json:
|
||||
ui.write_json(diff)
|
||||
else:
|
||||
from dvc.compare import show_diff
|
||||
|
||||
show_diff(
|
||||
diff,
|
||||
title="Param",
|
||||
markdown=self.args.show_md,
|
||||
markdown=self.args.markdown,
|
||||
no_path=self.args.no_path,
|
||||
show_changes=False,
|
||||
)
|
||||
@@ -103,15 +101,18 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Show only params that are stage dependencies.",
|
||||
)
|
||||
params_diff_parser.add_argument(
|
||||
"--json",
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show output in JSON format.",
|
||||
)
|
||||
params_diff_parser.add_argument(
|
||||
"--md",
|
||||
"--show-md",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="markdown",
|
||||
help="Show tabulated output in the Markdown format (GFM).",
|
||||
)
|
||||
params_diff_parser.add_argument(
|
||||
|
||||
+28
-20
@@ -4,6 +4,7 @@ import logging
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.render.utils import find_vega, render
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import format_link
|
||||
|
||||
@@ -35,43 +36,50 @@ class CmdPlots(CmdBase):
|
||||
return 1
|
||||
|
||||
try:
|
||||
plots = self._func(targets=self.args.targets, props=self._props())
|
||||
plots_data = self._func(
|
||||
targets=self.args.targets, props=self._props()
|
||||
)
|
||||
|
||||
if not plots_data:
|
||||
ui.error_write(
|
||||
"No plots were loaded, "
|
||||
"visualization file will not be created."
|
||||
)
|
||||
|
||||
if self.args.show_vega:
|
||||
target = self.args.targets[0]
|
||||
ui.write(plots[target])
|
||||
plot_json = find_vega(self.repo, plots_data, target)
|
||||
if plot_json:
|
||||
ui.write(plot_json)
|
||||
return 0
|
||||
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
return 1
|
||||
|
||||
if plots:
|
||||
rel: str = self.args.out or "plots.html"
|
||||
rel: str = self.args.out or "dvc_plots"
|
||||
path: Path = (Path.cwd() / rel).resolve()
|
||||
self.repo.plots.write_html(
|
||||
path, plots=plots, html_template_path=self.args.html_template
|
||||
index_path = render(
|
||||
self.repo,
|
||||
plots_data,
|
||||
path=path,
|
||||
html_template_path=self.args.html_template,
|
||||
)
|
||||
|
||||
assert (
|
||||
path.is_absolute()
|
||||
) # as_uri throws ValueError if not absolute
|
||||
url = path.as_uri()
|
||||
assert index_path.is_absolute()
|
||||
url = index_path.as_uri()
|
||||
ui.write(url)
|
||||
|
||||
if self.args.open:
|
||||
import webbrowser
|
||||
|
||||
opened = webbrowser.open(rel)
|
||||
opened = webbrowser.open(index_path)
|
||||
if not opened:
|
||||
ui.error_write(
|
||||
"Failed to open. Please try opening it manually."
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
ui.error_write(
|
||||
"No plots were loaded, visualization file will not be created."
|
||||
)
|
||||
return 0
|
||||
return 0
|
||||
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
return 1
|
||||
|
||||
|
||||
class CmdPlotsShow(CmdPlots):
|
||||
|
||||
@@ -86,7 +86,7 @@ class CmdRemoteDefault(CmdRemote):
|
||||
if self.args.name is None and not self.args.unset:
|
||||
conf = self.config.read(self.args.level)
|
||||
try:
|
||||
print(conf["core"]["remote"])
|
||||
ui.write(conf["core"]["remote"])
|
||||
except KeyError:
|
||||
ui.write("No default remote set")
|
||||
return 1
|
||||
|
||||
@@ -69,10 +69,8 @@ class CmdDataStatus(CmdDataBase):
|
||||
if self.args.quiet:
|
||||
return bool(st)
|
||||
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
ui.write(json.dumps(st))
|
||||
if self.args.json:
|
||||
ui.write_json(st)
|
||||
return 0
|
||||
|
||||
if st:
|
||||
|
||||
@@ -10,9 +10,12 @@ logger = logging.getLogger(__name__)
|
||||
class CmdVersion(CmdBaseNoRepo):
|
||||
def run(self):
|
||||
from dvc.info import get_dvc_info
|
||||
from dvc.updater import notify_updates
|
||||
|
||||
dvc_info = get_dvc_info()
|
||||
ui.write(dvc_info, force=True)
|
||||
|
||||
notify_updates()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -168,7 +168,10 @@ class TabularData(MutableSequence[Sequence["CellT"]]):
|
||||
def render(self, **kwargs: Any):
|
||||
from dvc.ui import ui
|
||||
|
||||
ui.table(self, headers=self.keys(), **kwargs)
|
||||
if kwargs.pop("csv", False):
|
||||
ui.write(self.to_csv(), end="")
|
||||
else:
|
||||
ui.table(self, headers=self.keys(), **kwargs)
|
||||
|
||||
def as_dict(
|
||||
self, cols: Iterable[str] = None
|
||||
|
||||
+30
-13
@@ -32,6 +32,18 @@ class RemoteNotFoundError(RemoteConfigError):
|
||||
pass
|
||||
|
||||
|
||||
class MachineConfigError(ConfigError):
|
||||
pass
|
||||
|
||||
|
||||
class NoMachineError(MachineConfigError):
|
||||
pass
|
||||
|
||||
|
||||
class MachineNotFoundError(MachineConfigError):
|
||||
pass
|
||||
|
||||
|
||||
@memoize
|
||||
def get_compiled_schema():
|
||||
from voluptuous import Schema
|
||||
@@ -64,8 +76,10 @@ class Config(dict):
|
||||
APPNAME = "dvc"
|
||||
APPAUTHOR = "iterative"
|
||||
|
||||
SYSTEM_LEVELS = ("system", "global")
|
||||
REPO_LEVELS = ("repo", "local")
|
||||
# In the order they shadow each other
|
||||
LEVELS = ("system", "global", "repo", "local")
|
||||
LEVELS = SYSTEM_LEVELS + REPO_LEVELS
|
||||
|
||||
CONFIG = "config"
|
||||
CONFIG_LOCAL = "config.local"
|
||||
@@ -167,7 +181,7 @@ class Config(dict):
|
||||
conf_obj = ConfigObj(fobj)
|
||||
else:
|
||||
conf_obj = ConfigObj()
|
||||
return _parse_remotes(_lower_keys(conf_obj.dict()))
|
||||
return _parse_named(_lower_keys(conf_obj.dict()))
|
||||
|
||||
def _save_config(self, level, conf_dict):
|
||||
from configobj import ConfigObj
|
||||
@@ -179,7 +193,7 @@ class Config(dict):
|
||||
|
||||
fs.makedirs(os.path.dirname(filename))
|
||||
|
||||
config = ConfigObj(_pack_remotes(conf_dict))
|
||||
config = ConfigObj(_pack_named(conf_dict))
|
||||
with fs.open(filename, "wb") as fobj:
|
||||
config.write(fobj)
|
||||
config.filename = filename
|
||||
@@ -251,6 +265,7 @@ class Config(dict):
|
||||
"key_path": func,
|
||||
}
|
||||
},
|
||||
"machine": {str: {"startup_script": func}},
|
||||
}
|
||||
return Schema(dirs_schema, extra=ALLOW_EXTRA)(conf)
|
||||
|
||||
@@ -273,7 +288,7 @@ class Config(dict):
|
||||
def edit(self, level=None, validate=True):
|
||||
# NOTE: we write to repo config by default, same as git config
|
||||
level = level or "repo"
|
||||
if level in {"repo", "local"} and self.dvc_dir is None:
|
||||
if self.dvc_dir is None and level in self.REPO_LEVELS:
|
||||
raise ConfigError("Not inside a DVC repo")
|
||||
|
||||
conf = self.load_one(level)
|
||||
@@ -300,27 +315,29 @@ class Config(dict):
|
||||
raise ConfigError(str(exc)) from None
|
||||
|
||||
|
||||
def _parse_remotes(conf):
|
||||
result = {"remote": {}}
|
||||
def _parse_named(conf):
|
||||
result = {"remote": {}, "machine": {}}
|
||||
|
||||
for section, val in conf.items():
|
||||
name = re_find(r'^\s*remote\s*"(.*)"\s*$', section)
|
||||
if name:
|
||||
result["remote"][name] = val
|
||||
match = re_find(r'^\s*(remote|machine)\s*"(.*)"\s*$', section)
|
||||
if match:
|
||||
key, name = match
|
||||
result[key][name] = val
|
||||
else:
|
||||
result[section] = val
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _pack_remotes(conf):
|
||||
def _pack_named(conf):
|
||||
# Drop empty sections
|
||||
result = compact(conf)
|
||||
|
||||
# Transform remote.name -> 'remote "name"'
|
||||
for name, val in conf["remote"].items():
|
||||
result[f'remote "{name}"'] = val
|
||||
result.pop("remote", None)
|
||||
for key in ("remote", "machine"):
|
||||
for name, val in conf[key].items():
|
||||
result[f'{key} "{name}"'] = val
|
||||
result.pop(key, None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
+36
-1
@@ -118,6 +118,7 @@ SCHEMA = {
|
||||
Optional("autostage", default=False): Bool,
|
||||
Optional("experiments"): Bool, # obsoleted
|
||||
Optional("check_update", default=True): Bool,
|
||||
"machine": Lower,
|
||||
},
|
||||
"cache": {
|
||||
"local": str,
|
||||
@@ -154,6 +155,9 @@ SCHEMA = {
|
||||
"grant_read_acp": str,
|
||||
"grant_write_acp": str,
|
||||
"grant_full_control": str,
|
||||
"cache_regions": bool,
|
||||
"read_timeout": Coerce(int),
|
||||
"connect_timeout": Coerce(int),
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
"gs": {
|
||||
@@ -221,13 +225,44 @@ SCHEMA = {
|
||||
)
|
||||
},
|
||||
"state": {
|
||||
"dir": str,
|
||||
"row_limit": All(Coerce(int), Range(1)), # obsoleted
|
||||
"row_cleanup_quota": All(Coerce(int), Range(0, 100)), # obsoleted
|
||||
},
|
||||
"index": {
|
||||
"dir": str,
|
||||
},
|
||||
"machine": {
|
||||
str: {
|
||||
"cloud": All(Lower, Choices("aws", "azure")),
|
||||
"region": All(
|
||||
Lower, Choices("us-west", "us-east", "eu-west", "eu-north")
|
||||
),
|
||||
"image": str,
|
||||
"name": str,
|
||||
"spot": Bool,
|
||||
"spot_price": Coerce(float),
|
||||
"instance_hdd_size": Coerce(int),
|
||||
"instance_type": Lower,
|
||||
"instance_gpu": Lower,
|
||||
"ssh_private": str,
|
||||
"startup_script": str,
|
||||
},
|
||||
},
|
||||
# section for experimental features
|
||||
"feature": {
|
||||
Optional("machine", default=False): Bool,
|
||||
# enabled by default. It's of no use, kept for backward compatibility.
|
||||
Optional("parametrization", default=True): Bool
|
||||
Optional("parametrization", default=True): Bool,
|
||||
},
|
||||
"plots": {"html_template": str},
|
||||
"exp": {
|
||||
"code": str,
|
||||
"data": str,
|
||||
"models": str,
|
||||
"metrics": str,
|
||||
"params": str,
|
||||
"plots": str,
|
||||
"live": str,
|
||||
},
|
||||
}
|
||||
|
||||
+14
-9
@@ -12,29 +12,34 @@ from dvc.utils import fix_env, is_binary
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
CREATE_NO_WINDOW = 0x08000000 # only available since Python 3.7 in subprocess.
|
||||
|
||||
|
||||
def _popen(cmd, **kwargs):
|
||||
prefix = [sys.executable]
|
||||
if not is_binary():
|
||||
prefix += [sys.argv[0]]
|
||||
|
||||
main_entrypoint = os.path.join(
|
||||
os.path.abspath(os.path.dirname(__file__)), "__main__.py"
|
||||
)
|
||||
prefix += [main_entrypoint]
|
||||
return Popen(prefix + cmd, close_fds=True, shell=False, **kwargs)
|
||||
|
||||
|
||||
def _spawn_windows(cmd, env):
|
||||
from subprocess import STARTF_USESHOWWINDOW, STARTUPINFO
|
||||
from subprocess import (
|
||||
CREATE_NEW_PROCESS_GROUP,
|
||||
STARTF_USESHOWWINDOW,
|
||||
STARTUPINFO,
|
||||
)
|
||||
|
||||
creationflags = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
# https://stackoverflow.com/a/7006424
|
||||
# https://bugs.python.org/issue41619
|
||||
creationflags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
|
||||
|
||||
startupinfo = STARTUPINFO()
|
||||
startupinfo.dwFlags |= STARTF_USESHOWWINDOW
|
||||
|
||||
_popen(
|
||||
cmd, env=env, creationflags=creationflags, startupinfo=startupinfo
|
||||
).communicate()
|
||||
_popen(cmd, env=env, creationflags=creationflags, startupinfo=startupinfo)
|
||||
|
||||
|
||||
def _spawn_posix(cmd, env):
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ class DataCloud:
|
||||
from dvc.objects.db import get_odb
|
||||
|
||||
cls, config, path_info = get_cloud_fs(self.repo, name=name)
|
||||
config["tmp_dir"] = self.repo.tmp_dir
|
||||
config["tmp_dir"] = self.repo.index_db_dir
|
||||
return get_odb(cls(**config), path_info, **config)
|
||||
|
||||
def push(
|
||||
|
||||
@@ -63,7 +63,7 @@ class RepoDependency(Dependency):
|
||||
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
|
||||
|
||||
def download(self, to, jobs=None):
|
||||
from dvc.checkout import checkout
|
||||
from dvc.objects.checkout import checkout
|
||||
|
||||
for odb, objs in self.get_used_objs().items():
|
||||
self.repo.cloud.pull(objs, jobs=jobs, odb=odb)
|
||||
|
||||
+34
-52
@@ -1,10 +1,7 @@
|
||||
import collections
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
|
||||
from voluptuous import MultipleInvalid
|
||||
from typing import TYPE_CHECKING, Any, Callable, Tuple, TypeVar, Union
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.parsing.versions import LOCKFILE_VERSION, SCHEMA_KWD
|
||||
@@ -12,23 +9,18 @@ from dvc.stage import serialize
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileBadNameError,
|
||||
StageFileDoesNotExistError,
|
||||
StageFileFormatError,
|
||||
StageFileIsNotDvcFileError,
|
||||
)
|
||||
from dvc.types import AnyPath
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.collections import apply_diff
|
||||
from dvc.utils.serialize import (
|
||||
dump_yaml,
|
||||
modify_yaml,
|
||||
parse_yaml,
|
||||
parse_yaml_for_update,
|
||||
)
|
||||
from dvc.utils.serialize import dump_yaml, modify_yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_T = TypeVar("_T")
|
||||
|
||||
DVC_FILE = "Dvcfile"
|
||||
DVC_FILE_SUFFIX = ".dvc"
|
||||
@@ -45,10 +37,6 @@ class FileIsGitIgnored(DvcException):
|
||||
)
|
||||
|
||||
|
||||
class LockfileCorruptedError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class ParametrizedDumpError(DvcException):
|
||||
pass
|
||||
|
||||
@@ -96,7 +84,7 @@ def check_dvcfile_path(repo, path):
|
||||
|
||||
|
||||
class FileMixin:
|
||||
SCHEMA = None
|
||||
SCHEMA: Callable[[_T], _T]
|
||||
|
||||
def __init__(self, repo, path, verify=True, **kwargs):
|
||||
self.repo = repo
|
||||
@@ -138,7 +126,11 @@ class FileMixin:
|
||||
if self._is_git_ignored():
|
||||
raise FileIsGitIgnored(self.path)
|
||||
|
||||
def _load(self):
|
||||
def load(self, **kwargs: Any) -> Any:
|
||||
d, _ = self._load(**kwargs)
|
||||
return d
|
||||
|
||||
def _load(self, **kwargs: Any) -> Tuple[Any, str]:
|
||||
# it raises the proper exceptions by priority:
|
||||
# 1. when the file doesn't exists
|
||||
# 2. filename is not a DVC file
|
||||
@@ -155,18 +147,23 @@ class FileMixin:
|
||||
raise StageFileIsNotDvcFileError(self.path)
|
||||
|
||||
self._check_gitignored()
|
||||
with self.repo.fs.open(self.path, encoding="utf-8") as fd:
|
||||
stage_text = fd.read()
|
||||
d = parse_yaml(stage_text, self.path)
|
||||
return self.validate(d, self.relpath), stage_text
|
||||
return self._load_yaml(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, d, fname=None):
|
||||
assert isinstance(cls.SCHEMA, collections.abc.Callable)
|
||||
try:
|
||||
return cls.SCHEMA(d) # pylint: disable=not-callable
|
||||
except MultipleInvalid as exc:
|
||||
raise StageFileFormatError(f"'{fname}' format error: {exc}")
|
||||
def validate(cls, d: _T, fname: str = None) -> _T:
|
||||
from dvc.utils.strictyaml import validate
|
||||
|
||||
return validate(d, cls.SCHEMA, path=fname) # type: ignore[arg-type]
|
||||
|
||||
def _load_yaml(self, **kwargs: Any) -> Tuple[Any, str]:
|
||||
from dvc.utils import strictyaml
|
||||
|
||||
return strictyaml.load(
|
||||
self.path,
|
||||
self.SCHEMA, # type: ignore[arg-type]
|
||||
self.repo.fs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def remove(self, force=False): # pylint: disable=unused-argument
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
@@ -298,10 +295,7 @@ class PipelineFile(FileMixin):
|
||||
if not self.exists():
|
||||
return
|
||||
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
d = parse_yaml_for_update(f.read(), self.path)
|
||||
|
||||
self.validate(d, self.path)
|
||||
d, _ = self._load_yaml(round_trip=True)
|
||||
if stage.name not in d.get("stages", {}):
|
||||
return
|
||||
|
||||
@@ -343,34 +337,25 @@ def migrate_lock_v1_to_v2(d, version_info):
|
||||
d["stages"] = stages
|
||||
|
||||
|
||||
def lockfile_schema(data: _T) -> _T:
|
||||
schema = get_lockfile_schema(data)
|
||||
return schema(data)
|
||||
|
||||
|
||||
class Lockfile(FileMixin):
|
||||
@classmethod
|
||||
def validate(cls, d, fname=None):
|
||||
schema = get_lockfile_schema(d)
|
||||
try:
|
||||
return schema(d)
|
||||
except MultipleInvalid as exc:
|
||||
raise StageFileFormatError(f"'{fname}' format error: {exc}")
|
||||
SCHEMA = staticmethod(lockfile_schema) # type: ignore[assignment]
|
||||
|
||||
def _verify_filename(self):
|
||||
pass # lockfile path is hardcoded, so no need to verify here
|
||||
|
||||
def _load(self):
|
||||
def _load(self, **kwargs: Any):
|
||||
try:
|
||||
return super()._load()
|
||||
return super()._load(**kwargs)
|
||||
except StageFileDoesNotExistError:
|
||||
# we still need to account for git-ignored dvc.lock file
|
||||
# even though it may not exist or have been .dvcignored
|
||||
self._check_gitignored()
|
||||
return {}, ""
|
||||
except StageFileFormatError as exc:
|
||||
raise LockfileCorruptedError(
|
||||
f"Lockfile '{self.relpath}' is corrupted."
|
||||
) from exc
|
||||
|
||||
def load(self):
|
||||
d, _ = self._load()
|
||||
return d
|
||||
|
||||
@property
|
||||
def latest_version_info(self):
|
||||
@@ -409,10 +394,7 @@ class Lockfile(FileMixin):
|
||||
if not self.exists():
|
||||
return
|
||||
|
||||
with open(self.path, encoding="utf-8") as f:
|
||||
d = parse_yaml_for_update(f.read(), self.path)
|
||||
self.validate(d, self.path)
|
||||
|
||||
d, _ = self._load_yaml(round_trip=True)
|
||||
version = LOCKFILE_VERSION.from_dict(d)
|
||||
data = d if version == LOCKFILE_VERSION.V1 else d.get("stages", {})
|
||||
if stage.name not in data:
|
||||
|
||||
@@ -9,3 +9,4 @@ DVCLIVE_RESUME = "DVCLIVE_RESUME"
|
||||
DVC_IGNORE_ISATTY = "DVC_IGNORE_ISATTY"
|
||||
DVC_EXP_GIT_REMOTE = "DVC_EXP_GIT_REMOTE"
|
||||
DVC_EXP_AUTO_PUSH = "DVC_EXP_AUTO_PUSH"
|
||||
DVC_NO_ANALYTICS = "DVC_NO_ANALYTICS"
|
||||
|
||||
@@ -364,3 +364,8 @@ class CircularImportError(DvcException):
|
||||
f"'{dep}' contains invalid circular import. "
|
||||
f"DVC repo '{a}' already imports from '{b}'."
|
||||
)
|
||||
|
||||
|
||||
class PrettyDvcException(DvcException):
|
||||
def __pretty_exc__(self, **kwargs):
|
||||
"""Print prettier exception message."""
|
||||
|
||||
@@ -2,6 +2,7 @@ import posixpath
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import utils # noqa: F401
|
||||
from .azure import AzureFileSystem
|
||||
from .gdrive import GDriveFileSystem
|
||||
from .gs import GSFileSystem
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ from dvc.path_info import CloudURLInfo
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import format_link
|
||||
|
||||
from .fsspec_wrapper import ObjectFSWrapper
|
||||
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DEFAULT_CREDS_STEPS = (
|
||||
@@ -39,7 +39,7 @@ def _az_config():
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class AzureFileSystem(ObjectFSWrapper):
|
||||
class AzureFileSystem(CallbackMixin, ObjectFSWrapper):
|
||||
scheme = Schemes.AZURE
|
||||
PATH_CLS = CloudURLInfo
|
||||
PARAM_CHECKSUM = "etag"
|
||||
|
||||
+123
-92
@@ -1,15 +1,19 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from functools import partial, partialmethod
|
||||
from functools import partialmethod
|
||||
from multiprocessing import cpu_count
|
||||
from typing import Any, ClassVar, Dict, FrozenSet, Optional
|
||||
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import URLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.progress import DEFAULT_CALLBACK, FsspecCallback
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.fs import makedirs, move
|
||||
from dvc.utils.http import open_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,13 +129,10 @@ class BaseFileSystem:
|
||||
f"dependencies: {missing}. {hint}"
|
||||
)
|
||||
|
||||
def open(self, path_info, mode: str = "r", encoding: str = None, **kwargs):
|
||||
if hasattr(self, "_generate_download_url"):
|
||||
# pylint:disable=no-member
|
||||
func = self._generate_download_url # type: ignore[attr-defined]
|
||||
get_url = partial(func, path_info)
|
||||
return open_url(get_url, mode=mode, encoding=encoding, **kwargs)
|
||||
def checksum(self, path_info) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def open(self, path_info, mode: str = "r", encoding: str = None, **kwargs):
|
||||
raise RemoteActionNotImplemented("open", self.scheme)
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
@@ -174,12 +175,6 @@ class BaseFileSystem:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def ls(self, path_info, detail=False):
|
||||
raise RemoteActionNotImplemented("ls", self.scheme)
|
||||
|
||||
def find(self, path_info, detail=False, prefix=None):
|
||||
raise RemoteActionNotImplemented("find", self.scheme)
|
||||
|
||||
def is_empty(self, path_info):
|
||||
return False
|
||||
|
||||
@@ -221,43 +216,58 @@ class BaseFileSystem:
|
||||
return False
|
||||
return hash_.endswith(cls.CHECKSUM_DIR_SUFFIX)
|
||||
|
||||
def upload(self, from_info, to_info, name=None, no_progress_bar=False):
|
||||
if not hasattr(self, "_upload"):
|
||||
raise RemoteActionNotImplemented("upload", self.scheme)
|
||||
def upload(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
total=None,
|
||||
desc=None,
|
||||
callback=None,
|
||||
no_progress_bar=False,
|
||||
**pbar_args,
|
||||
):
|
||||
is_file_obj = hasattr(from_info, "read")
|
||||
method = "upload_fobj" if is_file_obj else "put_file"
|
||||
if not hasattr(self, method):
|
||||
raise RemoteActionNotImplemented(method, self.scheme)
|
||||
|
||||
if to_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
if from_info.scheme != "local":
|
||||
raise NotImplementedError
|
||||
if not is_file_obj:
|
||||
desc = desc or from_info.name
|
||||
|
||||
logger.debug("Uploading '%s' to '%s'", from_info, to_info)
|
||||
stack = contextlib.ExitStack()
|
||||
if not callback:
|
||||
pbar = ui.progress(
|
||||
desc=desc,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=total or -1,
|
||||
**pbar_args,
|
||||
)
|
||||
stack.enter_context(pbar)
|
||||
callback = pbar.as_callback()
|
||||
if total:
|
||||
callback.set_size(total)
|
||||
|
||||
name = name or from_info.name
|
||||
with stack:
|
||||
if is_file_obj:
|
||||
wrapped = CallbackIOWrapper(
|
||||
callback.relative_update, from_info
|
||||
)
|
||||
# `size` is used to provide hints to the WebdavFileSystem
|
||||
# for legacy servers.
|
||||
# pylint: disable=no-member
|
||||
return self.upload_fobj(wrapped, to_info, size=total)
|
||||
|
||||
self._upload( # noqa, pylint: disable=no-member
|
||||
from_info.fspath,
|
||||
to_info,
|
||||
name=name,
|
||||
no_progress_bar=no_progress_bar,
|
||||
)
|
||||
if from_info.scheme != "local":
|
||||
raise NotImplementedError
|
||||
|
||||
def upload_fobj(
|
||||
self, fobj, to_info, no_progress_bar=False, size=None, **pbar_args
|
||||
):
|
||||
if not hasattr(self, "_upload_fobj"):
|
||||
raise RemoteActionNotImplemented("upload_fobj", self.scheme)
|
||||
|
||||
with Tqdm.wrapattr(
|
||||
fobj,
|
||||
"read",
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=size,
|
||||
**pbar_args,
|
||||
) as wrapped:
|
||||
self._upload_fobj( # pylint: disable=no-member
|
||||
wrapped, to_info, size=size
|
||||
logger.debug("Uploading '%s' to '%s'", from_info, to_info)
|
||||
# pylint: disable=no-member
|
||||
return self.put_file(
|
||||
os.fspath(from_info), to_info, callback=callback
|
||||
)
|
||||
|
||||
def download(
|
||||
@@ -265,84 +275,105 @@ class BaseFileSystem:
|
||||
from_info,
|
||||
to_info,
|
||||
name=None,
|
||||
callback=None,
|
||||
no_progress_bar=False,
|
||||
jobs=None,
|
||||
_only_file=False,
|
||||
**kwargs,
|
||||
):
|
||||
if not hasattr(self, "_download"):
|
||||
raise RemoteActionNotImplemented("download", self.scheme)
|
||||
if not hasattr(self, "get_file"):
|
||||
raise RemoteActionNotImplemented("get_file", self.scheme)
|
||||
|
||||
if from_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
if to_info.scheme == self.scheme != "local":
|
||||
self.copy(from_info, to_info)
|
||||
return 0
|
||||
|
||||
if to_info.scheme != "local":
|
||||
raise NotImplementedError
|
||||
|
||||
if not _only_file and self.isdir(from_info):
|
||||
return self._download_dir(
|
||||
from_info, to_info, name, no_progress_bar, jobs, **kwargs
|
||||
download_dir = not _only_file and self.isdir(from_info)
|
||||
|
||||
desc = name or to_info.name
|
||||
stack = contextlib.ExitStack()
|
||||
if not callback:
|
||||
pbar_kwargs = {"unit": "Files"} if download_dir else {}
|
||||
pbar = ui.progress(
|
||||
total=-1,
|
||||
desc="Downloading directory" if download_dir else desc,
|
||||
bytes=not download_dir,
|
||||
disable=no_progress_bar,
|
||||
**pbar_kwargs,
|
||||
)
|
||||
return self._download_file(from_info, to_info, name, no_progress_bar)
|
||||
stack.enter_context(pbar)
|
||||
callback = pbar.as_callback()
|
||||
|
||||
with stack:
|
||||
if download_dir:
|
||||
return self._download_dir(
|
||||
from_info, to_info, callback=callback, jobs=jobs, **kwargs
|
||||
)
|
||||
return self._download_file(from_info, to_info, callback=callback)
|
||||
|
||||
download_file = partialmethod(download, _only_file=True)
|
||||
|
||||
def _download_dir(
|
||||
self, from_info, to_info, name, no_progress_bar, jobs, **kwargs
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
jobs=None,
|
||||
**kwargs,
|
||||
):
|
||||
from_infos = list(self.walk_files(from_info, **kwargs))
|
||||
if not from_infos:
|
||||
makedirs(to_info, exist_ok=True)
|
||||
return None
|
||||
|
||||
to_infos = (
|
||||
to_info / info.relative_to(from_info) for info in from_infos
|
||||
)
|
||||
callback.set_size(len(from_infos))
|
||||
|
||||
with Tqdm(
|
||||
total=len(from_infos),
|
||||
desc="Downloading directory",
|
||||
unit="Files",
|
||||
disable=no_progress_bar,
|
||||
) as pbar:
|
||||
download_files = pbar.wrap_fn(
|
||||
partial(self._download_file, name=name, no_progress_bar=True)
|
||||
)
|
||||
max_workers = jobs or self.jobs
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(download_files, from_info, to_info)
|
||||
for from_info, to_info in zip(from_infos, to_infos)
|
||||
]
|
||||
download_files = FsspecCallback.wrap_fn(callback, self._download_file)
|
||||
max_workers = jobs or self.jobs
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(download_files, from_info, to_info)
|
||||
for from_info, to_info in zip(from_infos, to_infos)
|
||||
]
|
||||
|
||||
# NOTE: unlike pulling/fetching cache, where we need to
|
||||
# download everything we can, not raising an error here might
|
||||
# turn very ugly, as the user might think that he has
|
||||
# downloaded a complete directory, while having a partial one,
|
||||
# which might cause unexpected results in his pipeline.
|
||||
for future in as_completed(futures):
|
||||
# NOTE: executor won't let us raise until all futures that
|
||||
# it has are finished, so we need to cancel them ourselves
|
||||
# before re-raising.
|
||||
exc = future.exception()
|
||||
if exc:
|
||||
for entry in futures:
|
||||
entry.cancel()
|
||||
raise exc
|
||||
# NOTE: unlike pulling/fetching cache, where we need to
|
||||
# download everything we can, not raising an error here might
|
||||
# turn very ugly, as the user might think that he has
|
||||
# downloaded a complete directory, while having a partial one,
|
||||
# which might cause unexpected results in his pipeline.
|
||||
for future in as_completed(futures):
|
||||
# NOTE: executor won't let us raise until all futures that
|
||||
# it has are finished, so we need to cancel them ourselves
|
||||
# before re-raising.
|
||||
exc = future.exception()
|
||||
if exc:
|
||||
for entry in futures:
|
||||
entry.cancel()
|
||||
raise exc
|
||||
|
||||
def _download_file(self, from_info, to_info, name, no_progress_bar):
|
||||
def _download_file(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
):
|
||||
makedirs(to_info.parent, exist_ok=True)
|
||||
|
||||
logger.debug("Downloading '%s' to '%s'", from_info, to_info)
|
||||
name = name or to_info.name
|
||||
|
||||
tmp_file = tmp_fname(to_info)
|
||||
|
||||
self._download( # noqa, pylint: disable=no-member
|
||||
from_info, tmp_file, name=name, no_progress_bar=no_progress_bar
|
||||
)
|
||||
logger.debug("Downloading '%s' to '%s'", from_info, to_info)
|
||||
try:
|
||||
# noqa, pylint: disable=no-member
|
||||
self.get_file(from_info, tmp_file, callback=callback)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# do we need to rollback makedirs for previously not-existing
|
||||
# directories?
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(tmp_file)
|
||||
raise
|
||||
|
||||
move(tmp_file, to_info)
|
||||
|
||||
+13
-3
@@ -6,6 +6,7 @@ from dvc.exceptions import OutputNotFoundError
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.utils import relpath
|
||||
|
||||
from ..progress import DEFAULT_CALLBACK
|
||||
from ._metadata import Metadata
|
||||
from .base import BaseFileSystem
|
||||
|
||||
@@ -256,8 +257,17 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
return ret
|
||||
|
||||
def _download(self, from_info, to_file, **kwargs):
|
||||
def get_file(
|
||||
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
fs, path = self._get_fs_path(from_info)
|
||||
fs._download( # pylint: disable=protected-access
|
||||
path, to_file, **kwargs
|
||||
fs.get_file( # pylint: disable=protected-access
|
||||
path, to_file, callback=callback, **kwargs
|
||||
)
|
||||
|
||||
def checksum(self, path_info):
|
||||
info = self.info(path_info)
|
||||
md5 = info.get("md5")
|
||||
if md5:
|
||||
return md5
|
||||
raise NotImplementedError
|
||||
|
||||
+186
-103
@@ -1,13 +1,37 @@
|
||||
import os
|
||||
import shutil
|
||||
from functools import lru_cache
|
||||
from typing import (
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
Optional,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
from funcy import cached_property
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.path_info import URLInfo
|
||||
from dvc.progress import DEFAULT_CALLBACK
|
||||
|
||||
from .base import BaseFileSystem
|
||||
from .local import LocalFileSystem
|
||||
|
||||
FSPath = URLInfo
|
||||
AnyFSPath = Union[FSPath, str]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Iterable
|
||||
|
||||
from typing_extensions import Literal
|
||||
|
||||
|
||||
# An info() entry, might evolve to a TypedDict
|
||||
# in the future (e.g for properly type 'size' etc).
|
||||
Entry = Dict[str, Any]
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
@@ -19,18 +43,8 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
self.fs_args = {"skip_instance_cache": True}
|
||||
self.fs_args.update(self._prepare_credentials(**kwargs))
|
||||
|
||||
@cached_property
|
||||
def fs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _with_bucket(self, path):
|
||||
return str(path)
|
||||
|
||||
def _strip_bucket(self, entry):
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
def _get_kwargs_from_urls(urlpath: str) -> "Dict[str, Any]":
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
options = infer_storage_options(urlpath)
|
||||
@@ -38,6 +52,28 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
options.pop("protocol", None)
|
||||
return options
|
||||
|
||||
@cached_property
|
||||
def fs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _with_bucket(self, path: AnyFSPath) -> str:
|
||||
return str(path)
|
||||
|
||||
def _strip_bucket(self, entry: str) -> str:
|
||||
return entry
|
||||
|
||||
@overload
|
||||
def _strip_buckets(
|
||||
self, entries: "Iterable[Entry]", detail: "Literal[True]"
|
||||
) -> "Iterator[Entry]":
|
||||
...
|
||||
|
||||
@overload
|
||||
def _strip_buckets(
|
||||
self, entries: "Iterable[str]", detail: "Literal[False]" = False
|
||||
) -> "Iterator[str]":
|
||||
...
|
||||
|
||||
def _strip_buckets(self, entries, detail=False):
|
||||
for entry in entries:
|
||||
if detail:
|
||||
@@ -47,56 +83,91 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
entry = self._strip_bucket(entry)
|
||||
yield entry
|
||||
|
||||
def _entry_hook(self, entry):
|
||||
def _entry_hook(self, entry: "Entry") -> "Entry":
|
||||
"""Simple hook method to be overridden when wanted to process
|
||||
entries within info() and ls(detail=True) calls"""
|
||||
return entry
|
||||
|
||||
def _prepare_credentials(
|
||||
self, **config
|
||||
): # pylint: disable=unused-argument
|
||||
self, **config: Dict[str, Any] # pylint: disable=unused-argument
|
||||
) -> Dict[str, Any]:
|
||||
"""Prepare the arguments for authentication to the
|
||||
host filesystem"""
|
||||
return {}
|
||||
|
||||
def _isdir(self, path_info):
|
||||
def _isdir(self, path_info: FSPath) -> bool:
|
||||
return self.fs.isdir(self._with_bucket(path_info))
|
||||
|
||||
def isdir(self, path_info):
|
||||
def isdir(self, path_info: FSPath) -> bool:
|
||||
try:
|
||||
return self._isdir(path_info)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def isfile(self, path_info):
|
||||
def isfile(self, path_info: FSPath) -> bool:
|
||||
try:
|
||||
return not self._isdir(path_info)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def is_empty(self, path_info):
|
||||
def is_empty(self, path_info: FSPath) -> bool:
|
||||
entry = self.info(path_info)
|
||||
if entry["type"] == "directory":
|
||||
return not self.fs.ls(self._with_bucket(path_info))
|
||||
return entry["size"] == 0
|
||||
|
||||
def open(
|
||||
self, path_info, mode="r", **kwargs
|
||||
): # pylint: disable=arguments-differ
|
||||
self,
|
||||
path_info: FSPath,
|
||||
mode: str = "r",
|
||||
encoding: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> "IO": # pylint: disable=arguments-differ
|
||||
return self.fs.open(self._with_bucket(path_info), mode=mode)
|
||||
|
||||
def copy(self, from_info, to_info):
|
||||
def checksum(self, path_info: FSPath) -> str:
|
||||
return self.fs.checksum(self._with_bucket(path_info))
|
||||
|
||||
def copy(self, from_info: FSPath, to_info: FSPath) -> None:
|
||||
self.makedirs(to_info.parent)
|
||||
self.fs.copy(self._with_bucket(from_info), self._with_bucket(to_info))
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
def exists(self, path_info: FSPath) -> bool:
|
||||
return self.fs.exists(self._with_bucket(path_info))
|
||||
|
||||
@overload
|
||||
def ls(
|
||||
self, path_info: FSPath, detail: "Literal[True]"
|
||||
) -> "Iterator[Entry]":
|
||||
...
|
||||
|
||||
@overload
|
||||
def ls(self, path_info: FSPath, detail: "Literal[False]") -> Iterator[str]:
|
||||
...
|
||||
|
||||
def ls(self, path_info, detail=False):
|
||||
path = self._with_bucket(path_info)
|
||||
files = self.fs.ls(path, detail=detail)
|
||||
yield from self._strip_buckets(files, detail=detail)
|
||||
|
||||
@overload
|
||||
def find(
|
||||
self,
|
||||
path_info: FSPath,
|
||||
detail: "Literal[True]",
|
||||
prefix: Optional[bool],
|
||||
) -> "Iterator[Entry]":
|
||||
...
|
||||
|
||||
@overload
|
||||
def find(
|
||||
self,
|
||||
path_info: FSPath,
|
||||
detail: "Literal[False]",
|
||||
prefix: Optional[bool],
|
||||
) -> Iterator[str]:
|
||||
...
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def find(self, path_info, detail=False, prefix=None):
|
||||
path = self._with_bucket(path_info)
|
||||
@@ -106,63 +177,56 @@ class FSSpecWrapper(BaseFileSystem):
|
||||
|
||||
yield from self._strip_buckets(files, detail=detail)
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
def walk_files(self, path_info: FSPath, **kwargs) -> Iterator[FSPath]:
|
||||
for file in self.find(path_info, **kwargs):
|
||||
yield path_info.replace(path=file)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
def move(self, from_info: FSPath, to_info: FSPath) -> None:
|
||||
self.fs.move(self._with_bucket(from_info), self._with_bucket(to_info))
|
||||
|
||||
def remove(self, path_info):
|
||||
def remove(self, path_info: FSPath) -> None:
|
||||
self.fs.rm_file(self._with_bucket(path_info))
|
||||
|
||||
def info(self, path_info):
|
||||
def info(self, path_info: FSPath) -> "Entry":
|
||||
info = self.fs.info(self._with_bucket(path_info)).copy()
|
||||
info = self._entry_hook(info)
|
||||
info["name"] = self._strip_bucket(info["name"])
|
||||
return info
|
||||
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
def makedirs(self, path_info: FSPath, **kwargs) -> None:
|
||||
self.fs.makedirs(
|
||||
self._with_bucket(path_info), exist_ok=kwargs.pop("exist_ok", True)
|
||||
)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, size=None):
|
||||
self.makedirs(to_info.parent)
|
||||
with self.open(to_info, "wb") as fdest:
|
||||
shutil.copyfileobj(fobj, fdest, length=fdest.blocksize)
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **kwargs
|
||||
):
|
||||
self.makedirs(to_info.parent)
|
||||
size = os.path.getsize(from_file)
|
||||
with open(from_file, "rb") as fobj:
|
||||
self.upload_fobj(
|
||||
fobj,
|
||||
to_info,
|
||||
size=size,
|
||||
desc=name,
|
||||
no_progress_bar=no_progress_bar,
|
||||
)
|
||||
def put_file(
|
||||
self,
|
||||
from_file: FSPath,
|
||||
to_info: FSPath,
|
||||
callback: Any = DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.fs.put_file(
|
||||
from_file, self._with_bucket(to_info), callback=callback, **kwargs
|
||||
)
|
||||
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **pbar_args
|
||||
):
|
||||
total = self.getsize(self._with_bucket(from_info))
|
||||
with self.open(from_info, "rb") as fobj:
|
||||
with Tqdm.wrapattr(
|
||||
fobj,
|
||||
"read",
|
||||
desc=name,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=total,
|
||||
**pbar_args,
|
||||
) as wrapped:
|
||||
with open(to_file, "wb") as fdest:
|
||||
shutil.copyfileobj(wrapped, fdest, length=fobj.blocksize)
|
||||
def get_file(
|
||||
self,
|
||||
from_info: FSPath,
|
||||
to_info: FSPath,
|
||||
callback: Any = DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.fs.get_file(
|
||||
self._with_bucket(from_info), to_info, callback=callback, **kwargs
|
||||
)
|
||||
|
||||
def upload_fobj(self, fobj: IO, to_info: FSPath, **kwargs) -> None:
|
||||
self.makedirs(to_info.parent)
|
||||
with self.open(to_info, "wb") as fdest:
|
||||
shutil.copyfileobj(
|
||||
fobj, fdest, length=getattr(fdest, "blocksize", None)
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
@@ -170,21 +234,21 @@ class ObjectFSWrapper(FSSpecWrapper):
|
||||
TRAVERSE_PREFIX_LEN = 3
|
||||
|
||||
@lru_cache(512)
|
||||
def _with_bucket(self, path):
|
||||
def _with_bucket(self, path: AnyFSPath) -> str:
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
return f"{path.bucket}/{path.path}"
|
||||
return path
|
||||
return str(path)
|
||||
|
||||
def _strip_bucket(self, entry):
|
||||
def _strip_bucket(self, entry: str) -> str:
|
||||
try:
|
||||
bucket, path = entry.split("/", 1)
|
||||
except ValueError:
|
||||
# If there is no path attached, only returns
|
||||
# the bucket (top-level).
|
||||
bucket, path = entry, None
|
||||
bucket, path = entry, ""
|
||||
return path or bucket
|
||||
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
def makedirs(self, path_info: FSPath, **kwargs) -> None:
|
||||
# For object storages make this method a no-op. The original
|
||||
# fs.makedirs() method will only check if the bucket exists
|
||||
# and create if it doesn't though we don't want to support
|
||||
@@ -192,7 +256,7 @@ class ObjectFSWrapper(FSSpecWrapper):
|
||||
# simply ignore all mkdir()/makedirs() calls.
|
||||
return None
|
||||
|
||||
def _isdir(self, path_info):
|
||||
def _isdir(self, path_info: FSPath) -> bool:
|
||||
# Directory in object storages are interpreted differently
|
||||
# among different fsspec providers, so this logic is a temporary
|
||||
# measure for us to adapt as of now. It checks whether it is a
|
||||
@@ -217,7 +281,7 @@ class ObjectFSWrapper(FSSpecWrapper):
|
||||
files = self.fs.find(path, detail=detail)
|
||||
|
||||
if detail:
|
||||
files = files.values()
|
||||
files = list(files.values())
|
||||
|
||||
# When calling find() on a file, it returns the same file in a list.
|
||||
# For object-based storages, the same behavior applies to empty
|
||||
@@ -227,45 +291,64 @@ class ObjectFSWrapper(FSSpecWrapper):
|
||||
if len(files) == 1 and files[0] == path and self.isdir(path_info):
|
||||
return None
|
||||
|
||||
yield from self._strip_buckets(files, detail=detail)
|
||||
yield from self._strip_buckets( # pylint: disable=not-an-iterable
|
||||
files, detail=detail
|
||||
)
|
||||
|
||||
|
||||
_LOCAL_FS = LocalFileSystem()
|
||||
# pylint: disable=arguments-differ
|
||||
class NoDirectoriesMixin:
|
||||
def isdir(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def isfile(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
def find(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def walk(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def walk_files(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def ls(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class CallbackMixin:
|
||||
"""Use the native ``get_file()``/``put_file()`` APIs
|
||||
if the target filesystem supports callbacks."""
|
||||
"""Provides callback support for the filesystem that don't support yet."""
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **pbar_args
|
||||
def put_file(
|
||||
self,
|
||||
from_file,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
):
|
||||
with Tqdm(
|
||||
desc=name,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=-1,
|
||||
**pbar_args,
|
||||
) as pbar:
|
||||
self.fs.put_file(
|
||||
os.fspath(from_file),
|
||||
self._with_bucket(to_info),
|
||||
callback=pbar.as_callback(_LOCAL_FS, from_file),
|
||||
)
|
||||
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
|
||||
"""Add compatibility support for Callback."""
|
||||
# pylint: disable=protected-access
|
||||
self.makedirs(to_info.parent)
|
||||
size = os.path.getsize(from_file)
|
||||
with open(from_file, "rb") as fobj:
|
||||
callback.set_size(size)
|
||||
wrapped = CallbackIOWrapper(callback.relative_update, fobj)
|
||||
self.upload_fobj(wrapped, to_info)
|
||||
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **pbar_args
|
||||
def get_file(
|
||||
self,
|
||||
from_info,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
):
|
||||
with Tqdm(
|
||||
desc=name,
|
||||
disable=no_progress_bar,
|
||||
bytes=True,
|
||||
total=-1,
|
||||
**pbar_args,
|
||||
) as pbar:
|
||||
self.fs.get_file(
|
||||
self._with_bucket(from_info),
|
||||
os.fspath(to_file),
|
||||
callback=pbar.as_callback(self, from_info),
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
total: int = self.getsize(from_info)
|
||||
if total:
|
||||
callback.set_size(total)
|
||||
|
||||
with self.open(from_info, "rb") as fobj, open(to_info, "wb") as fdest:
|
||||
wrapped = CallbackIOWrapper(callback.relative_update, fobj)
|
||||
shutil.copyfileobj(wrapped, fdest, length=fobj.blocksize)
|
||||
|
||||
+13
-6
@@ -12,7 +12,7 @@ from dvc.path_info import CloudURLInfo
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import format_link, tmp_fname
|
||||
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
|
||||
@@ -79,9 +79,7 @@ class GDriveURLInfo(CloudURLInfo):
|
||||
self._spath = re.sub("/{2,}", "/", self._spath.rstrip("/"))
|
||||
|
||||
|
||||
class GDriveFileSystem(
|
||||
CallbackMixin, FSSpecWrapper
|
||||
): # pylint:disable=abstract-method
|
||||
class GDriveFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
scheme = Schemes.GDRIVE
|
||||
PATH_CLS = GDriveURLInfo
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
@@ -298,7 +296,16 @@ class GDriveFileSystem(
|
||||
|
||||
return super()._with_bucket(path)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, size: int = None):
|
||||
def _strip_bucket(self, entry):
|
||||
try:
|
||||
bucket, path = entry.split("/", 1)
|
||||
except ValueError:
|
||||
# If there is no path attached, only returns
|
||||
# the bucket (top-level).
|
||||
bucket, path = entry, None
|
||||
return path or bucket
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
rpath = self._with_bucket(to_info)
|
||||
self.makedirs(os.path.dirname(rpath))
|
||||
return self.fs.upload_fobj(fobj, rpath, size=size)
|
||||
return self.fs.upload_fobj(fobj, rpath, **kwargs)
|
||||
|
||||
+18
-24
@@ -1,9 +1,11 @@
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
from dvc.utils import is_exec, relpath
|
||||
|
||||
from ..progress import DEFAULT_CALLBACK
|
||||
from .base import BaseFileSystem
|
||||
|
||||
|
||||
@@ -96,30 +98,20 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
if not self.exists(path_info):
|
||||
return False
|
||||
|
||||
mode = self.stat(path_info).st_mode
|
||||
mode = self.info(path_info)["mode"]
|
||||
return is_exec(mode)
|
||||
|
||||
def info(self, path_info):
|
||||
key = self._get_key(path_info)
|
||||
try:
|
||||
st = self.trie.stat(key)
|
||||
return self.trie.info(key)
|
||||
except KeyError:
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), path_info
|
||||
)
|
||||
return {
|
||||
"size": st.st_size,
|
||||
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
}
|
||||
|
||||
def stat(self, path):
|
||||
key = self._get_key(path)
|
||||
try:
|
||||
return self.trie.stat(key)
|
||||
except KeyError:
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), path
|
||||
)
|
||||
def checksum(self, path_info):
|
||||
return self.info(path_info)["sha"]
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
for root, _, files in self.walk(path_info, **kwargs):
|
||||
@@ -127,16 +119,18 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
# NOTE: os.path.join is ~5.5 times slower
|
||||
yield f"{root}{os.sep}{file}"
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **kwargs
|
||||
def get_file(
|
||||
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
import shutil
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
total = self.getsize(from_info)
|
||||
if total:
|
||||
callback.set_size(total)
|
||||
|
||||
with open(to_file, "wb+") as to_fobj:
|
||||
with Tqdm.wrapattr(
|
||||
to_fobj, "write", desc=name, disable=no_progress_bar
|
||||
) as wrapped:
|
||||
with self.open(from_info, "rb", **kwargs) as from_fobj:
|
||||
shutil.copyfileobj(from_fobj, wrapped)
|
||||
with self.open(from_info, "rb", **kwargs) as from_fobj:
|
||||
with open(to_file, "wb+") as to_fobj:
|
||||
wrapped = CallbackIOWrapper(
|
||||
callback.relative_update, from_fobj
|
||||
)
|
||||
shutil.copyfileobj(wrapped, to_fobj)
|
||||
|
||||
+4
-5
@@ -6,17 +6,16 @@ from funcy import cached_property, wrap_prop
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .fsspec_wrapper import ObjectFSWrapper
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class GSFileSystem(ObjectFSWrapper):
|
||||
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
|
||||
|
||||
|
||||
class GSFileSystem(CallbackMixin, ObjectFSWrapper):
|
||||
scheme = Schemes.GS
|
||||
PATH_CLS = CloudURLInfo
|
||||
REQUIRES = {"gcsfs": "gcsfs"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
DETAIL_FIELDS = frozenset(("etag", "size"))
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
login_info = {"consistency": None}
|
||||
|
||||
+64
-257
@@ -1,283 +1,90 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections import deque
|
||||
from contextlib import closing, contextmanager
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import fix_env, tmp_fname
|
||||
from dvc.utils import fix_env
|
||||
|
||||
from .base import BaseFileSystem, RemoteCmdError
|
||||
from .pool import get_connection
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CHECKSUM_REGEX = re.compile(r".*\t.*\t(?P<checksum>.*)")
|
||||
|
||||
|
||||
def _hadoop_fs(cmd, user=None):
|
||||
cmd = "hadoop fs -" + cmd
|
||||
if user:
|
||||
cmd = f"HADOOP_USER_NAME={user} " + cmd
|
||||
|
||||
# NOTE: close_fds doesn't work with redirected stdin/stdout/stderr.
|
||||
# See https://github.com/iterative/dvc/issues/1197.
|
||||
close_fds = os.name != "nt"
|
||||
|
||||
executable = os.getenv("SHELL") if os.name != "nt" else None
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
close_fds=close_fds,
|
||||
executable=executable,
|
||||
env=fix_env(os.environ),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
out, err = p.communicate()
|
||||
if p.returncode != 0:
|
||||
raise RemoteCmdError("hdfs", cmd, p.returncode, err)
|
||||
return out.decode("utf-8")
|
||||
|
||||
|
||||
def _group(regex, s, gname):
|
||||
match = re.match(regex, s)
|
||||
assert match is not None
|
||||
return match.group(gname)
|
||||
|
||||
|
||||
def _hadoop_fs_checksum(path_info):
|
||||
# NOTE: pyarrow doesn't support checksum, so we need to use hadoop
|
||||
regex = r".*\t.*\t(?P<checksum>.*)"
|
||||
stdout = _hadoop_fs(f"checksum {path_info.url}", user=path_info.user)
|
||||
return _group(regex, stdout, "checksum")
|
||||
|
||||
|
||||
class HDFSFileSystem(BaseFileSystem):
|
||||
# pylint: disable=abstract-method
|
||||
class HDFSFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
scheme = Schemes.HDFS
|
||||
REQUIRES = {"pyarrow": "pyarrow"}
|
||||
REGEX = r"^hdfs://((?P<user>.*)@)?.*$"
|
||||
REQUIRES = {"fsspec": "fsspec", "pyarrow": "pyarrow"}
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
self.host = config["host"]
|
||||
self.user = config.get("user")
|
||||
self.port = config.get("port")
|
||||
|
||||
self.kerb_ticket = config.get("kerb_ticket")
|
||||
def _with_bucket(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
return path.path
|
||||
return super()._with_bucket(path)
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.hdfs import PyArrowHDFS
|
||||
from fsspec.implementations.arrow import HadoopFileSystem
|
||||
|
||||
# pylint:disable=protected-access
|
||||
return PyArrowHDFS._get_kwargs_from_urls(urlpath)
|
||||
return HadoopFileSystem._get_kwargs_from_urls(urlpath)
|
||||
|
||||
def hdfs(self, path_info):
|
||||
import pyarrow.fs
|
||||
def _prepare_credentials(self, **config):
|
||||
return config
|
||||
|
||||
# NOTE: HadoopFileSystem is not meant to be closed by us and doesn't
|
||||
# have close() method or alternative, so we add a noop one to satisfy
|
||||
# our connection pool.
|
||||
class HDFSConnection(pyarrow.fs.HadoopFileSystem):
|
||||
def close(self):
|
||||
pass
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.arrow import HadoopFileSystem
|
||||
|
||||
return get_connection(
|
||||
HDFSConnection,
|
||||
path_info.host,
|
||||
path_info.port,
|
||||
user=path_info.user,
|
||||
kerb_ticket=self.kerb_ticket,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def open(self, path_info, mode="r", encoding=None, **kwargs):
|
||||
assert mode in {"r", "rt", "rb"}
|
||||
|
||||
try:
|
||||
with self.hdfs(path_info) as hdfs, closing(
|
||||
hdfs.open_input_stream(path_info.path)
|
||||
) as fd:
|
||||
if mode == "rb":
|
||||
yield fd
|
||||
else:
|
||||
yield io.TextIOWrapper(fd, encoding=encoding)
|
||||
except OSError as e:
|
||||
# Empty .errno and not specific enough error class in pyarrow,
|
||||
# see https://issues.apache.org/jira/browse/ARROW-6248
|
||||
if "file does not exist" in str(e):
|
||||
raise FileNotFoundError(*e.args)
|
||||
raise
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
assert not isinstance(path_info, list)
|
||||
assert path_info.scheme == "hdfs"
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
import pyarrow.fs
|
||||
|
||||
file_info = hdfs.get_file_info(path_info.path)
|
||||
return file_info.type != pyarrow.fs.FileType.NotFound
|
||||
|
||||
def _walk(self, hdfs, root, topdown=True):
|
||||
import posixpath
|
||||
|
||||
from pyarrow.fs import FileSelector, FileType
|
||||
|
||||
dirs = deque()
|
||||
nondirs = deque()
|
||||
|
||||
selector = FileSelector(root)
|
||||
for entry in hdfs.get_file_info(selector):
|
||||
if entry.type == FileType.Directory:
|
||||
dirs.append(entry.base_name)
|
||||
else:
|
||||
nondirs.append(entry.base_name)
|
||||
|
||||
if topdown:
|
||||
yield root, dirs, nondirs
|
||||
|
||||
for dname in dirs:
|
||||
# NOTE: posixpath.join() is slower
|
||||
yield from self._walk(
|
||||
hdfs, f"{root}{posixpath.sep}{dname}", topdown=topdown
|
||||
)
|
||||
|
||||
if not topdown:
|
||||
yield root, dirs, nondirs
|
||||
|
||||
def walk(self, top, topdown=True, onerror=None, **kwargs):
|
||||
if not self.isdir(top):
|
||||
return
|
||||
|
||||
with self.hdfs(top) as hdfs:
|
||||
for root, dnames, fnames in self._walk(
|
||||
hdfs, top.path, topdown=topdown
|
||||
):
|
||||
yield top.replace(path=root), dnames, fnames
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
for root, _, fnames in self.walk(path_info):
|
||||
yield from (root / fname for fname in fnames)
|
||||
|
||||
def remove(self, path_info):
|
||||
if path_info.scheme != "hdfs":
|
||||
raise NotImplementedError
|
||||
|
||||
if self.exists(path_info):
|
||||
logger.debug(f"Removing {path_info.path}")
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
import pyarrow.fs
|
||||
|
||||
file_info = hdfs.get_file_info(path_info.path)
|
||||
if file_info.type == pyarrow.fs.FileType.Directory:
|
||||
hdfs.delete_dir(path_info.path)
|
||||
else:
|
||||
hdfs.delete_file(path_info.path)
|
||||
|
||||
def makedirs(self, path_info, **kwargs):
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
# NOTE: fs.create_dir creates parents by default
|
||||
hdfs.create_dir(path_info.path)
|
||||
|
||||
def copy(self, from_info, to_info, **_kwargs):
|
||||
# NOTE: hdfs.copy_file is not supported yet in pyarrow
|
||||
with self.hdfs(to_info) as hdfs:
|
||||
# NOTE: this is how `hadoop fs -cp` works too: it copies through
|
||||
# your local machine.
|
||||
with closing(hdfs.open_input_stream(from_info.path)) as from_fobj:
|
||||
tmp_info = to_info.parent / tmp_fname(to_info.name)
|
||||
try:
|
||||
with closing(
|
||||
hdfs.open_output_stream(tmp_info.path)
|
||||
) as tmp_fobj:
|
||||
shutil.copyfileobj(from_fobj, tmp_fobj)
|
||||
hdfs.move(tmp_info.path, to_info.path)
|
||||
except Exception:
|
||||
self.remove(tmp_info)
|
||||
raise
|
||||
|
||||
def isfile(self, path_info):
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
import pyarrow.fs
|
||||
|
||||
file_info = hdfs.get_file_info(path_info.path)
|
||||
return file_info.type == pyarrow.fs.FileType.File
|
||||
|
||||
def isdir(self, path_info):
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
import pyarrow.fs
|
||||
|
||||
file_info = hdfs.get_file_info(path_info.path)
|
||||
return file_info.type == pyarrow.fs.FileType.Directory
|
||||
|
||||
def info(self, path_info):
|
||||
with self.hdfs(path_info) as hdfs:
|
||||
from pyarrow.fs import FileType
|
||||
|
||||
file_info = hdfs.get_file_info(path_info.path)
|
||||
if file_info.type is FileType.Directory:
|
||||
kind = "directory"
|
||||
elif file_info.type is FileType.File:
|
||||
kind = "file"
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), path_info
|
||||
)
|
||||
|
||||
return {"size": file_info.size, "type": kind}
|
||||
return HadoopFileSystem(**self.fs_args)
|
||||
|
||||
def checksum(self, path_info):
|
||||
return HashInfo(
|
||||
"checksum",
|
||||
_hadoop_fs_checksum(path_info),
|
||||
size=self.getsize(path_info),
|
||||
return self._checksum(path_info)
|
||||
|
||||
def _checksum(self, path_info, **kwargs):
|
||||
# PyArrow doesn't natively support retrieving the
|
||||
# checksum, so we have to use hadoop fs
|
||||
|
||||
result = self._run_command(
|
||||
f"checksum {path_info.url}",
|
||||
env=fix_env(os.environ),
|
||||
user=path_info.user,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
with self.hdfs(to_info) as hdfs:
|
||||
with hdfs.open_output_stream(to_info.path) as fdest:
|
||||
shutil.copyfileobj(fobj, fdest)
|
||||
match = CHECKSUM_REGEX.match(result)
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
with self.hdfs(to_info) as hdfs:
|
||||
tmp_file = tmp_fname(to_info.path)
|
||||
total = os.path.getsize(from_file)
|
||||
with open(from_file, "rb") as fobj:
|
||||
with Tqdm.wrapattr(
|
||||
fobj,
|
||||
"read",
|
||||
desc=name,
|
||||
total=total,
|
||||
disable=no_progress_bar,
|
||||
) as wrapped:
|
||||
with hdfs.open_output_stream(tmp_file) as sobj:
|
||||
sobj.write(wrapped.read())
|
||||
hdfs.move(tmp_file, to_info.path)
|
||||
return match.group("checksum")
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
with self.hdfs(from_info) as hdfs:
|
||||
file_info = hdfs.get_file_info(from_info.path)
|
||||
total = file_info.size
|
||||
with open(to_file, "wb+") as fobj:
|
||||
with Tqdm.wrapattr(
|
||||
fobj,
|
||||
"write",
|
||||
desc=name,
|
||||
total=total,
|
||||
disable=no_progress_bar,
|
||||
) as wrapped:
|
||||
with hdfs.open_input_stream(from_info.path) as sobj:
|
||||
wrapped.write(sobj.read())
|
||||
def _run_command(self, cmd, env=None, user=None):
|
||||
cmd = "hadoop fs -" + cmd
|
||||
if user:
|
||||
cmd = f"HADOOP_USER_NAME={user} " + cmd
|
||||
|
||||
# NOTE: close_fds doesn't work with redirected stdin/stdout/stderr.
|
||||
# See https://github.com/iterative/dvc/issues/1197.
|
||||
close_fds = os.name != "nt"
|
||||
|
||||
executable = os.getenv("SHELL") if os.name != "nt" else None
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
close_fds=close_fds,
|
||||
executable=executable,
|
||||
env=env or os.environ,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
out, err = p.communicate()
|
||||
|
||||
if p.returncode != 0:
|
||||
raise subprocess.CalledProcessError(p.returncode, cmd, out, err)
|
||||
else:
|
||||
return out.decode("utf-8")
|
||||
|
||||
+91
-155
@@ -1,20 +1,12 @@
|
||||
import logging
|
||||
import os.path
|
||||
import threading
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from funcy import cached_property, memoize, wrap_prop, wrap_with
|
||||
from funcy import cached_property, memoize, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.exceptions import DvcException, HTTPError
|
||||
from dvc.path_info import HTTPURLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import BaseFileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from .fsspec_wrapper import FSSpecWrapper, NoDirectoriesMixin
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@@ -26,171 +18,115 @@ def ask_password(host, user):
|
||||
)
|
||||
|
||||
|
||||
class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
def make_context(ssl_verify):
|
||||
if isinstance(ssl_verify, bool) or ssl_verify is None:
|
||||
return ssl_verify
|
||||
|
||||
# If this is a path, then we will create an
|
||||
# SSL context for it, and load the given certificate.
|
||||
import ssl
|
||||
|
||||
context = ssl.create_default_context()
|
||||
context.load_verify_locations(ssl_verify)
|
||||
return context
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class HTTPFileSystem(NoDirectoriesMixin, FSSpecWrapper):
|
||||
scheme = Schemes.HTTP
|
||||
PATH_CLS = HTTPURLInfo
|
||||
PARAM_CHECKSUM = "etag"
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
REQUIRES = {"aiohttp": "aiohttp", "aiohttp-retry": "aiohttp_retry"}
|
||||
CAN_TRAVERSE = False
|
||||
REQUIRES = {"requests": "requests"}
|
||||
|
||||
SESSION_RETRIES = 5
|
||||
SESSION_BACKOFF_FACTOR = 0.1
|
||||
REQUEST_TIMEOUT = 60
|
||||
CHUNK_SIZE = 2 ** 16
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
def _prepare_credentials(self, **config):
|
||||
import aiohttp
|
||||
from fsspec.asyn import fsspec_loop
|
||||
|
||||
self.user = config.get("user", None)
|
||||
from dvc.config import ConfigError
|
||||
|
||||
self.auth = config.get("auth", None)
|
||||
self.custom_auth_header = config.get("custom_auth_header", None)
|
||||
self.password = config.get("password", None)
|
||||
self.ask_password = config.get("ask_password", False)
|
||||
self.headers = {}
|
||||
self.ssl_verify = config.get("ssl_verify", True)
|
||||
self.method = config.get("method", "POST")
|
||||
credentials = {}
|
||||
client_kwargs = credentials.setdefault("client_kwargs", {})
|
||||
|
||||
def _auth_method(self, url):
|
||||
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
|
||||
if config.get("auth"):
|
||||
user = config.get("user")
|
||||
password = config.get("password")
|
||||
custom_auth_header = config.get("custom_auth_header")
|
||||
|
||||
if self.auth:
|
||||
if self.ask_password and self.password is None:
|
||||
self.password = ask_password(urlparse(url).hostname, self.user)
|
||||
if self.auth == "basic":
|
||||
return HTTPBasicAuth(self.user, self.password)
|
||||
if self.auth == "digest":
|
||||
return HTTPDigestAuth(self.user, self.password)
|
||||
if self.auth == "custom" and self.custom_auth_header:
|
||||
self.headers.update({self.custom_auth_header: self.password})
|
||||
return None
|
||||
if password is None and config.get("ask_password"):
|
||||
password = ask_password(config.get("url"), user or "custom")
|
||||
|
||||
def _generate_download_url(self, path_info):
|
||||
return path_info.url
|
||||
auth_method = config["auth"]
|
||||
if auth_method == "basic":
|
||||
if user is None or password is None:
|
||||
raise ConfigError(
|
||||
"HTTP 'basic' authentication require both "
|
||||
"'user' and 'password'"
|
||||
)
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def _session(self):
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
client_kwargs["auth"] = aiohttp.BasicAuth(user, password)
|
||||
elif auth_method == "custom":
|
||||
if custom_auth_header is None or password is None:
|
||||
raise ConfigError(
|
||||
"HTTP 'custom' authentication require both "
|
||||
"'custom_auth_header' and 'password'"
|
||||
)
|
||||
credentials["headers"] = {custom_auth_header: password}
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"Auth method {auth_method!r} is not supported."
|
||||
)
|
||||
|
||||
session = requests.Session()
|
||||
if "ssl_verify" in config:
|
||||
with fsspec_loop():
|
||||
client_kwargs["connector"] = aiohttp.TCPConnector(
|
||||
ssl=make_context(config["ssl_verify"])
|
||||
)
|
||||
|
||||
session.verify = self.ssl_verify
|
||||
# Allow reading proxy configurations from the environment.
|
||||
client_kwargs["trust_env"] = True
|
||||
|
||||
retries = Retry(
|
||||
total=self.SESSION_RETRIES,
|
||||
backoff_factor=self.SESSION_BACKOFF_FACTOR,
|
||||
credentials["get_client"] = self.get_client
|
||||
self.upload_method = config.get("method", "POST")
|
||||
return credentials
|
||||
|
||||
async def get_client(self, **kwargs):
|
||||
import aiohttp
|
||||
from aiohttp_retry import ExponentialRetry, RetryClient
|
||||
|
||||
kwargs["retry_options"] = ExponentialRetry(
|
||||
attempts=self.SESSION_RETRIES,
|
||||
factor=self.SESSION_BACKOFF_FACTOR,
|
||||
max_timeout=self.REQUEST_TIMEOUT,
|
||||
exceptions=[aiohttp.ClientError],
|
||||
)
|
||||
|
||||
session.mount("http://", HTTPAdapter(max_retries=retries))
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
# The default timeout for the aiohttp is 300 seconds
|
||||
# which is too low for DVC's interactions (especially
|
||||
# on the read) when dealing with large data blobs. We
|
||||
# unlimit the total time to read, and only limit the
|
||||
# time that is spent when connecting to the remote server.
|
||||
kwargs["timeout"] = aiohttp.ClientTimeout(
|
||||
total=None,
|
||||
connect=self.REQUEST_TIMEOUT,
|
||||
sock_connect=self.REQUEST_TIMEOUT,
|
||||
sock_read=None,
|
||||
)
|
||||
|
||||
return session
|
||||
return RetryClient(**kwargs)
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
import requests
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.http import (
|
||||
HTTPFileSystem as _HTTPFileSystem,
|
||||
)
|
||||
|
||||
kwargs.setdefault("allow_redirects", True)
|
||||
kwargs.setdefault("timeout", self.REQUEST_TIMEOUT)
|
||||
return _HTTPFileSystem(**self.fs_args)
|
||||
|
||||
try:
|
||||
res = self._session.request(
|
||||
method,
|
||||
url,
|
||||
auth=self._auth_method(url),
|
||||
headers=self.headers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
redirect_no_location = (
|
||||
kwargs["allow_redirects"]
|
||||
and res.status_code in (301, 302)
|
||||
and "location" not in res.headers
|
||||
)
|
||||
|
||||
if redirect_no_location:
|
||||
# AWS s3 doesn't like to add a location header to its redirects
|
||||
# from https://s3.amazonaws.com/<bucket name>/* type URLs.
|
||||
# This should be treated as an error
|
||||
raise requests.exceptions.RequestException
|
||||
|
||||
return res
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
raise DvcException(f"could not perform a {method} request")
|
||||
|
||||
def _head(self, url):
|
||||
response = self.request("HEAD", url)
|
||||
if response.ok:
|
||||
return response
|
||||
|
||||
# Sometimes servers are configured to forbid HEAD requests
|
||||
# Context: https://github.com/iterative/dvc/issues/4131
|
||||
with self.request("GET", url, stream=True) as r:
|
||||
if r.ok:
|
||||
return r
|
||||
|
||||
return response
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
res = self._head(path_info.url)
|
||||
if res.status_code == 404:
|
||||
return False
|
||||
if bool(res):
|
||||
return True
|
||||
raise HTTPError(res.status_code, res.reason)
|
||||
|
||||
def info(self, path_info):
|
||||
resp = self._head(path_info.url)
|
||||
etag = resp.headers.get("ETag") or resp.headers.get("Content-MD5")
|
||||
size = self._content_length(resp)
|
||||
return {"etag": etag, "size": size, "type": "file"}
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
def chunks(fobj):
|
||||
while True:
|
||||
chunk = fobj.read(self.CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
response = self.request(self.method, to_info.url, data=chunks(fobj))
|
||||
if response.status_code not in (200, 201):
|
||||
raise HTTPError(response.status_code, response.reason)
|
||||
|
||||
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
|
||||
response = self.request("GET", from_info.url, stream=True)
|
||||
if response.status_code != 200:
|
||||
raise HTTPError(response.status_code, response.reason)
|
||||
with open(to_file, "wb") as fd:
|
||||
with Tqdm.wrapattr(
|
||||
fd,
|
||||
"write",
|
||||
total=None
|
||||
if no_progress_bar
|
||||
else self._content_length(response),
|
||||
leave=False,
|
||||
desc=from_info.url if name is None else name,
|
||||
disable=no_progress_bar,
|
||||
) as fd_wrapped:
|
||||
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
|
||||
fd_wrapped.write(chunk)
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
with open(from_file, "rb") as fobj:
|
||||
self.upload_fobj(
|
||||
fobj,
|
||||
to_info,
|
||||
size=None if no_progress_bar else os.path.getsize(from_file),
|
||||
no_progress_bar=no_progress_bar,
|
||||
desc=name or to_info.url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _content_length(response) -> Optional[int]:
|
||||
res = response.headers.get("Content-Length")
|
||||
return int(res) if res else None
|
||||
def _entry_hook(self, entry):
|
||||
entry["checksum"] = entry.get("ETag") or entry.get("Content-MD5")
|
||||
return entry
|
||||
|
||||
+17
-26
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.scheme import Schemes
|
||||
@@ -8,6 +7,7 @@ from dvc.system import System
|
||||
from dvc.utils import is_exec, tmp_fname
|
||||
from dvc.utils.fs import copy_fobj_to_file, copyfile, makedirs, move, remove
|
||||
|
||||
from ..progress import DEFAULT_CALLBACK
|
||||
from .base import BaseFileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,7 +34,12 @@ class LocalFileSystem(BaseFileSystem):
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
assert isinstance(path_info, str) or path_info.scheme == "local"
|
||||
return self.fs.exists(path_info)
|
||||
# TODO: replace this with os.path.exists once the problem is fixed on
|
||||
# the fsspec https://github.com/intake/filesystem_spec/issues/742
|
||||
return os.path.lexists(path_info)
|
||||
|
||||
def checksum(self, path_info) -> str:
|
||||
return self.fs.checksum(path_info)
|
||||
|
||||
def isfile(self, path_info) -> bool:
|
||||
return os.path.isfile(path_info)
|
||||
@@ -83,12 +88,9 @@ class LocalFileSystem(BaseFileSystem):
|
||||
makedirs(path_info, exist_ok=kwargs.pop("exist_ok", True))
|
||||
|
||||
def isexec(self, path_info):
|
||||
mode = self.stat(path_info).st_mode
|
||||
mode = self.info(path_info)["mode"]
|
||||
return is_exec(mode)
|
||||
|
||||
def stat(self, path):
|
||||
return os.stat(path)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
if (
|
||||
isinstance(from_info, PathInfo) and from_info.scheme != "local"
|
||||
@@ -107,7 +109,7 @@ class LocalFileSystem(BaseFileSystem):
|
||||
self.remove(tmp_info)
|
||||
raise
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self.makedirs(to_info.parent)
|
||||
tmp_info = to_info.parent / tmp_fname("")
|
||||
try:
|
||||
@@ -154,29 +156,18 @@ class LocalFileSystem(BaseFileSystem):
|
||||
def reflink(self, from_info, to_info):
|
||||
System.reflink(from_info, to_info)
|
||||
|
||||
@staticmethod
|
||||
def info(path_info):
|
||||
st = os.stat(path_info)
|
||||
return {
|
||||
"size": st.st_size,
|
||||
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
|
||||
}
|
||||
def info(self, path_info):
|
||||
return self.fs.info(path_info)
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
def put_file(
|
||||
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
makedirs(to_info.parent, exist_ok=True)
|
||||
|
||||
tmp_file = tmp_fname(to_info)
|
||||
copyfile(
|
||||
from_file, tmp_file, name=name, no_progress_bar=no_progress_bar
|
||||
)
|
||||
copyfile(from_file, tmp_file, callback=callback)
|
||||
os.replace(tmp_file, to_info)
|
||||
|
||||
@staticmethod
|
||||
def _download(
|
||||
from_info, to_file, name=None, no_progress_bar=False, **_kwargs
|
||||
def get_file(
|
||||
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
copyfile(
|
||||
from_info, to_file, no_progress_bar=no_progress_bar, name=name
|
||||
)
|
||||
copyfile(from_info, to_file, callback=callback)
|
||||
|
||||
+2
-2
@@ -7,13 +7,13 @@ from funcy import cached_property, wrap_prop
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
|
||||
from .fsspec_wrapper import ObjectFSWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class OSSFileSystem(CallbackMixin, ObjectFSWrapper):
|
||||
class OSSFileSystem(ObjectFSWrapper):
|
||||
scheme = Schemes.OSS
|
||||
PATH_CLS = CloudURLInfo
|
||||
REQUIRES = {"ossfs": "ossfs"}
|
||||
|
||||
+25
-19
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from itertools import takewhile
|
||||
@@ -10,6 +9,7 @@ from funcy import lfilter, wrap_with
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
|
||||
from ..progress import DEFAULT_CALLBACK
|
||||
from .base import BaseFileSystem
|
||||
from .dvc import DvcFileSystem
|
||||
|
||||
@@ -256,8 +256,8 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
return False
|
||||
|
||||
try:
|
||||
st = fs.stat(path)
|
||||
return stat.S_ISDIR(st.st_mode)
|
||||
info = fs.info(path)
|
||||
return info["type"] == "directory"
|
||||
except (OSError, ValueError):
|
||||
# from CPython's os.path.isdir()
|
||||
pass
|
||||
@@ -287,8 +287,8 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
return False
|
||||
|
||||
try:
|
||||
st = fs.stat(path)
|
||||
return stat.S_ISREG(st.st_mode)
|
||||
info = fs.info(path)
|
||||
return info["type"] == "file"
|
||||
except (OSError, ValueError):
|
||||
# from CPython's os.path.isfile()
|
||||
pass
|
||||
@@ -313,10 +313,6 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
return dvc_fs.isexec(path_info)
|
||||
return fs.isexec(path_info)
|
||||
|
||||
def stat(self, path):
|
||||
fs, _ = self._get_fs_pair(path)
|
||||
return fs.stat(path)
|
||||
|
||||
def _dvc_walk(self, walk):
|
||||
try:
|
||||
root, dirs, files = next(walk)
|
||||
@@ -453,19 +449,21 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
for fname in files:
|
||||
yield PathInfo(root) / fname
|
||||
|
||||
def _download(self, from_info, to_file, **kwargs):
|
||||
def get_file(
|
||||
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
fs, dvc_fs = self._get_fs_pair(from_info)
|
||||
try:
|
||||
fs._download( # pylint: disable=protected-access
|
||||
from_info, to_file, **kwargs
|
||||
fs.get_file( # pylint: disable=protected-access
|
||||
from_info, to_file, callback=callback, **kwargs
|
||||
)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
dvc_fs._download( # pylint: disable=protected-access
|
||||
from_info, to_file, **kwargs
|
||||
dvc_fs.get_file( # pylint: disable=protected-access
|
||||
from_info, to_file, callback=callback, **kwargs
|
||||
)
|
||||
|
||||
def metadata(self, path):
|
||||
@@ -478,11 +476,11 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
with suppress(FileNotFoundError):
|
||||
dvc_meta = dvc_fs.metadata(path_info)
|
||||
|
||||
stat_result = None
|
||||
info_result = None
|
||||
with suppress(FileNotFoundError):
|
||||
stat_result = fs.stat(path_info)
|
||||
info_result = fs.info(path_info)
|
||||
|
||||
if not stat_result and not dvc_meta:
|
||||
if not info_result and not dvc_meta:
|
||||
raise FileNotFoundError
|
||||
|
||||
from ._metadata import Metadata
|
||||
@@ -492,13 +490,13 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
repo=self._get_repo(abspath) or self._main_repo,
|
||||
)
|
||||
|
||||
isdir = bool(stat_result) and stat.S_ISDIR(stat_result.st_mode)
|
||||
isdir = bool(info_result) and info_result["type"] == "directory"
|
||||
meta.isdir = meta.isdir or isdir
|
||||
|
||||
if not dvc_meta:
|
||||
from dvc.utils import is_exec
|
||||
|
||||
meta.is_exec = bool(stat_result) and is_exec(stat_result.st_mode)
|
||||
meta.is_exec = bool(info_result) and is_exec(info_result["mode"])
|
||||
return meta
|
||||
|
||||
def info(self, path_info):
|
||||
@@ -508,3 +506,11 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
return fs.info(path_info)
|
||||
except FileNotFoundError:
|
||||
return dvc_fs.info(path_info)
|
||||
|
||||
def checksum(self, path_info):
|
||||
fs, dvc_fs = self._get_fs_pair(path_info)
|
||||
|
||||
try:
|
||||
return fs.checksum(path_info)
|
||||
except FileNotFoundError:
|
||||
return dvc_fs.checksum(path_info)
|
||||
|
||||
+30
-30
@@ -6,7 +6,7 @@ from collections import defaultdict
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.progress import DEFAULT_CALLBACK
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .fsspec_wrapper import ObjectFSWrapper
|
||||
@@ -68,12 +68,12 @@ class BaseS3FileSystem(ObjectFSWrapper):
|
||||
self._transfer_config = None
|
||||
config_path = os.environ.get("AWS_CONFIG_FILE", _AWS_CONFIG_PATH)
|
||||
if not os.path.exists(config_path):
|
||||
return None
|
||||
return {}
|
||||
|
||||
config = load_config(config_path)
|
||||
profile_config = config["profiles"].get(profile or "default")
|
||||
if not profile_config:
|
||||
return None
|
||||
return {}
|
||||
|
||||
s3_config = profile_config.get("s3", {})
|
||||
return self._split_s3_config(s3_config)
|
||||
@@ -99,6 +99,11 @@ class BaseS3FileSystem(ObjectFSWrapper):
|
||||
client["endpoint_url"] = config.get("endpointurl")
|
||||
client["verify"] = config.get("ssl_verify")
|
||||
|
||||
# timeout configuration
|
||||
config_kwargs = login_info["config_kwargs"]
|
||||
config_kwargs["read_timeout"] = config.get("read_timeout")
|
||||
config_kwargs["connect_timeout"] = config.get("connect_timeout")
|
||||
|
||||
# encryptions
|
||||
additional = login_info["s3_additional_kwargs"]
|
||||
additional["ServerSideEncryption"] = config.get("sse")
|
||||
@@ -123,6 +128,14 @@ class BaseS3FileSystem(ObjectFSWrapper):
|
||||
if shared_creds:
|
||||
os.environ.setdefault("AWS_SHARED_CREDENTIALS_FILE", shared_creds)
|
||||
|
||||
if (
|
||||
client["region_name"] is None
|
||||
and session_config["s3"].get("region_name") is None
|
||||
and os.getenv("AWS_REGION") is None
|
||||
):
|
||||
# Enable bucket region caching
|
||||
login_info["cache_regions"] = config.get("cache_regions", True)
|
||||
|
||||
config_path = config.get("configpath")
|
||||
if config_path:
|
||||
os.environ.setdefault("AWS_CONFIG_FILE", config_path)
|
||||
@@ -196,36 +209,23 @@ class S3FileSystem(BaseS3FileSystem): # pylint:disable=abstract-method
|
||||
return bucket.Object(path_info.path)
|
||||
|
||||
@_translate_exceptions
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **pbar_args
|
||||
def put_file(
|
||||
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
total = os.path.getsize(from_file)
|
||||
with Tqdm(
|
||||
disable=no_progress_bar,
|
||||
total=total,
|
||||
bytes=True,
|
||||
desc=name,
|
||||
**pbar_args,
|
||||
) as pbar:
|
||||
obj = self._get_obj(to_info)
|
||||
obj.upload_file(
|
||||
from_file,
|
||||
Callback=pbar.update,
|
||||
ExtraArgs=self.fs_args.get("s3_additional_kwargs"),
|
||||
Config=self._transfer_config,
|
||||
)
|
||||
callback.set_size(os.path.getsize(from_file))
|
||||
obj = self._get_obj(to_info)
|
||||
obj.upload_file(
|
||||
from_file,
|
||||
Callback=callback.relative_update,
|
||||
ExtraArgs=self.fs_args.get("s3_additional_kwargs"),
|
||||
Config=self._transfer_config,
|
||||
)
|
||||
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
|
||||
|
||||
@_translate_exceptions
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **pbar_args
|
||||
def get_file(
|
||||
self, from_info, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
obj = self._get_obj(from_info)
|
||||
with Tqdm(
|
||||
disable=no_progress_bar,
|
||||
total=obj.content_length,
|
||||
bytes=True,
|
||||
desc=name,
|
||||
**pbar_args,
|
||||
) as pbar:
|
||||
obj.download_file(to_file, Callback=pbar.update)
|
||||
callback.set_size(obj.content_length)
|
||||
obj.download_file(to_info, Callback=callback.relative_update)
|
||||
|
||||
+29
-24
@@ -2,13 +2,14 @@ import getpass
|
||||
import os.path
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, first, memoize, silent, wrap_prop, wrap_with
|
||||
from funcy import cached_property, memoize, silent, wrap_prop, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils.fs import as_atomic
|
||||
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
from ..progress import DEFAULT_CALLBACK
|
||||
from .fsspec_wrapper import FSSpecWrapper
|
||||
|
||||
_SSH_TIMEOUT = 60 * 30
|
||||
_SSH_CONFIG_FILE = os.path.expanduser(os.path.join("~", ".ssh", "config"))
|
||||
@@ -26,7 +27,7 @@ def ask_password(host, user, port):
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class SSHFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
class SSHFileSystem(FSSpecWrapper):
|
||||
scheme = Schemes.SSH
|
||||
REQUIRES = {"sshfs": "sshfs"}
|
||||
|
||||
@@ -35,10 +36,8 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
|
||||
def _with_bucket(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
path_str = path.path
|
||||
else:
|
||||
path_str = super()._with_bucket(path)
|
||||
return os.path.normpath(path_str)
|
||||
return path.path
|
||||
return super()._with_bucket(path)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
self.CAN_TRAVERSE = True
|
||||
@@ -66,16 +65,25 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
or self.DEFAULT_PORT
|
||||
)
|
||||
|
||||
login_info["password"] = config.get("password")
|
||||
|
||||
if user_ssh_config.get("IdentityFile"):
|
||||
config.setdefault(
|
||||
"keyfile", first(user_ssh_config.get("IdentityFile"))
|
||||
if config.get("ask_password") and config.get("password") is None:
|
||||
config["password"] = ask_password(
|
||||
login_info["host"], login_info["username"], login_info["port"]
|
||||
)
|
||||
|
||||
keyfile = config.get("keyfile")
|
||||
if keyfile:
|
||||
login_info["client_keys"] = [os.path.expanduser(keyfile)]
|
||||
login_info["password"] = config.get("password")
|
||||
login_info["passphrase"] = config.get("password")
|
||||
|
||||
raw_keys = []
|
||||
if config.get("keyfile"):
|
||||
raw_keys.append(config.get("keyfile"))
|
||||
elif user_ssh_config.get("IdentityFile"):
|
||||
raw_keys.extend(user_ssh_config.get("IdentityFile"))
|
||||
|
||||
if raw_keys:
|
||||
login_info["client_keys"] = [
|
||||
os.path.expanduser(key) for key in raw_keys
|
||||
]
|
||||
|
||||
login_info["timeout"] = config.get("timeout", _SSH_TIMEOUT)
|
||||
|
||||
# These two settings fine tune the asyncssh to use the
|
||||
@@ -96,11 +104,6 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
login_info["agent_forwarding"] = config.get("agent_forwarding", True)
|
||||
login_info["proxy_command"] = user_ssh_config.get("ProxyCommand")
|
||||
|
||||
if config.get("ask_password") and login_info["password"] is None:
|
||||
login_info["password"] = ask_password(
|
||||
login_info["host"], login_info["username"], login_info["port"]
|
||||
)
|
||||
|
||||
# We are going to automatically add stuff to known_hosts
|
||||
# something like paramiko's AutoAddPolicy()
|
||||
login_info["known_hosts"] = None
|
||||
@@ -116,10 +119,12 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
# Ensure that if an interrupt happens during the transfer, we don't
|
||||
# pollute the cache.
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, *args, **kwargs):
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super()._upload_fobj(fobj, tmp_file, *args, **kwargs)
|
||||
super().upload_fobj(fobj, tmp_file, **kwargs)
|
||||
|
||||
def _upload(self, from_file, to_info, *args, **kwargs):
|
||||
def put_file(
|
||||
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super()._upload(from_file, tmp_file, *args, **kwargs)
|
||||
super().put_file(from_file, tmp_file, callback=callback, **kwargs)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .local import LocalFileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import AnyPath, DvcPath
|
||||
|
||||
from .base import BaseFileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def transfer(
|
||||
from_fs: "BaseFileSystem",
|
||||
from_info: "AnyPath",
|
||||
to_fs: "BaseFileSystem",
|
||||
to_info: "DvcPath",
|
||||
move: bool = False,
|
||||
) -> None:
|
||||
use_move = isinstance(from_fs, type(to_fs)) and move
|
||||
try:
|
||||
if use_move:
|
||||
return to_fs.move(from_info, to_info)
|
||||
|
||||
if isinstance(from_fs, LocalFileSystem):
|
||||
if not isinstance(from_info, from_fs.PATH_CLS):
|
||||
from_info = from_fs.PATH_CLS(from_info)
|
||||
return to_fs.upload(from_info, to_info)
|
||||
|
||||
if isinstance(to_fs, LocalFileSystem):
|
||||
return from_fs.download_file(from_info, to_info)
|
||||
|
||||
with from_fs.open(from_info, mode="rb") as fobj:
|
||||
size = from_fs.getsize(from_info)
|
||||
return to_fs.upload(fobj, to_info, total=size)
|
||||
except OSError as exc:
|
||||
# If the target file already exists, we are going to simply
|
||||
# ignore the exception (#4992).
|
||||
#
|
||||
# On Windows, it is not always guaranteed that you'll get
|
||||
# FileExistsError (it might be PermissionError or a bare OSError)
|
||||
# but all of those exceptions raised from the original
|
||||
# FileExistsError so we have a separate check for that.
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", to_info)
|
||||
if use_move:
|
||||
from_fs.remove(from_info)
|
||||
return None
|
||||
|
||||
raise
|
||||
+4
-7
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
|
||||
@@ -71,15 +70,13 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
|
||||
return WebdavFileSystem(**self.fs_args)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, size: int = None):
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
rpath = self.translate_path_info(to_info)
|
||||
self.makedirs(os.path.dirname(rpath))
|
||||
size = kwargs.get("size")
|
||||
# using upload_fileobj to directly upload fileobj rather than buffering
|
||||
# and using overwrite=True to avoid check for an extra exists call,
|
||||
# as caller should ensure that the file does not exist beforehand.
|
||||
return self.fs.client.upload_fileobj(
|
||||
fobj, rpath, overwrite=True, size=size
|
||||
)
|
||||
return self.fs.upload_fileobj(fobj, rpath, overwrite=True, size=size)
|
||||
|
||||
@lru_cache(512)
|
||||
def translate_path_info(self, path):
|
||||
@@ -87,7 +84,7 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
|
||||
return path.path[len(self.prefix) :].lstrip("/")
|
||||
return path
|
||||
|
||||
_with_bucket = translate_path_info
|
||||
_with_bucket = translate_path_info # type: ignore
|
||||
|
||||
|
||||
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
|
||||
|
||||
+20
-142
@@ -1,51 +1,24 @@
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.path_info import CloudURLInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import BaseFileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# pylint:disable=abstract-method
|
||||
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
|
||||
|
||||
|
||||
def update_pbar(pbar, total):
|
||||
"""Update pbar to accept the two arguments passed by hdfs"""
|
||||
|
||||
def update(_, bytes_transfered):
|
||||
if bytes_transfered == -1:
|
||||
pbar.update_to(total)
|
||||
return
|
||||
pbar.update_to(bytes_transfered)
|
||||
|
||||
return update
|
||||
|
||||
|
||||
class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
class WebHDFSFileSystem(CallbackMixin, FSSpecWrapper):
|
||||
scheme = Schemes.WEBHDFS
|
||||
PATH_CLS = CloudURLInfo
|
||||
REQUIRES = {"hdfs": "hdfs"}
|
||||
REQUIRES = {"fsspec": "fsspec"}
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
self.host = config["host"]
|
||||
self.user = config.get("user")
|
||||
self.port = config.get("port")
|
||||
|
||||
self.hdfscli_config = config.get("hdfscli_config")
|
||||
self.token = config.get("webhdfs_token")
|
||||
self.alias = config.get("webhdfs_alias")
|
||||
def _with_bucket(self, path):
|
||||
if isinstance(path, self.PATH_CLS):
|
||||
return f"/{path.path.rstrip('/')}"
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
@@ -57,115 +30,20 @@ class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
|
||||
)
|
||||
)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
if "webhdfs_token" in config:
|
||||
config["token"] = config.pop("webhdfs_token")
|
||||
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def hdfs_client(self):
|
||||
import hdfs
|
||||
def fs(self):
|
||||
from fsspec.implementations.webhdfs import WebHDFS
|
||||
|
||||
logger.debug("HDFSConfig: %s", self.hdfscli_config)
|
||||
|
||||
try:
|
||||
return hdfs.config.Config(self.hdfscli_config).get_client(
|
||||
self.alias
|
||||
)
|
||||
except hdfs.util.HdfsError as exc:
|
||||
exc_msg = str(exc)
|
||||
errors = (
|
||||
"No alias specified",
|
||||
"Invalid configuration file",
|
||||
f"Alias {self.alias} not found",
|
||||
)
|
||||
if not any(err in exc_msg for err in errors):
|
||||
raise
|
||||
|
||||
http_url = f"http://{self.host}:{self.port}"
|
||||
logger.debug("URL: %s", http_url)
|
||||
|
||||
if self.token is not None:
|
||||
client = hdfs.TokenClient(http_url, token=self.token, root="/")
|
||||
else:
|
||||
client = hdfs.InsecureClient(
|
||||
http_url, user=self.user, root="/"
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
@contextmanager
|
||||
def open(self, path_info, mode="r", encoding=None, **kwargs):
|
||||
assert mode in {"r", "rt", "rb"}
|
||||
|
||||
with self.hdfs_client.read(
|
||||
path_info.path, encoding=encoding
|
||||
) as reader:
|
||||
yield reader
|
||||
|
||||
def walk_files(self, path_info, **kwargs):
|
||||
if not self.exists(path_info):
|
||||
return
|
||||
|
||||
root = path_info.path
|
||||
for path, _, fnames in self.hdfs_client.walk(root):
|
||||
for fname in fnames:
|
||||
yield path_info.replace(path=posixpath.join(path, fname))
|
||||
|
||||
def remove(self, path_info):
|
||||
if path_info.scheme != self.scheme:
|
||||
raise NotImplementedError
|
||||
|
||||
self.hdfs_client.delete(path_info.path)
|
||||
|
||||
def exists(self, path_info) -> bool:
|
||||
assert not isinstance(path_info, list)
|
||||
assert path_info.scheme == "webhdfs"
|
||||
|
||||
status = self.hdfs_client.status(path_info.path, strict=False)
|
||||
return status is not None
|
||||
|
||||
def info(self, path_info):
|
||||
st = self.hdfs_client.status(path_info.path)
|
||||
return {"size": st["length"], "type": "file"}
|
||||
return WebHDFS(**self.fs_args)
|
||||
|
||||
def checksum(self, path_info):
|
||||
return HashInfo(
|
||||
"checksum",
|
||||
self.hdfs_client.checksum(path_info.path)["bytes"],
|
||||
size=self.hdfs_client.status(path_info.path)["length"],
|
||||
)
|
||||
|
||||
def copy(self, from_info, to_info, **_kwargs):
|
||||
with self.hdfs_client.read(from_info.path) as reader:
|
||||
with self.hdfs_client.write(to_info.path) as writer:
|
||||
shutil.copyfileobj(reader, writer)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
self.hdfs_client.makedirs(to_info.parent.path)
|
||||
self.hdfs_client.rename(from_info.path, to_info.path)
|
||||
|
||||
def _upload_fobj(self, fobj, to_info, **kwargs):
|
||||
with self.hdfs_client.write(to_info.path) as fdest:
|
||||
shutil.copyfileobj(fobj, fdest)
|
||||
|
||||
def _upload(
|
||||
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
total = os.path.getsize(from_file)
|
||||
with Tqdm(
|
||||
desc=name, total=total, disable=no_progress_bar, bytes=True
|
||||
) as pbar:
|
||||
self.hdfs_client.upload(
|
||||
to_info.path,
|
||||
from_file,
|
||||
overwrite=True,
|
||||
progress=update_pbar(pbar, total),
|
||||
)
|
||||
|
||||
def _download(
|
||||
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
|
||||
):
|
||||
total = self.getsize(from_info)
|
||||
with Tqdm(
|
||||
desc=name, total=total, disable=no_progress_bar, bytes=True
|
||||
) as pbar:
|
||||
self.hdfs_client.download(
|
||||
from_info.path, to_file, progress=update_pbar(pbar, total)
|
||||
)
|
||||
path = self._with_bucket(path_info)
|
||||
ukey = self.fs.ukey(path)
|
||||
return ukey["bytes"]
|
||||
|
||||
+1
-2
@@ -11,7 +11,6 @@ from dvc.fs.base import BaseFileSystem
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.pathspec_math import PatternInfo, merge_patterns
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.system import System
|
||||
from dvc.types import AnyPath, List, Optional
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.collections import PathStringTrie
|
||||
@@ -84,7 +83,7 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
else:
|
||||
return False
|
||||
|
||||
if not System.is_unix():
|
||||
if os.name == "nt":
|
||||
path = normalize_file(path)
|
||||
return path
|
||||
|
||||
|
||||
+8
-5
@@ -136,11 +136,14 @@ def _get_supported_remotes():
|
||||
|
||||
|
||||
def get_fs_type(path):
|
||||
|
||||
partition = {
|
||||
pathlib.Path(part.mountpoint): (part.fstype + " on " + part.device)
|
||||
for part in psutil.disk_partitions(all=True)
|
||||
}
|
||||
partition = {}
|
||||
for part in psutil.disk_partitions(all=True):
|
||||
if part.fstype != "":
|
||||
try:
|
||||
mountpoint = pathlib.Path(part.mountpoint).resolve()
|
||||
partition[mountpoint] = part.fstype + " on " + part.device
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
# need to follow the symlink: https://github.com/iterative/dvc/issues/5065
|
||||
path = pathlib.Path(path).resolve()
|
||||
|
||||
@@ -142,9 +142,22 @@ class LoggerHandler(logging.StreamHandler):
|
||||
super().handleError(record)
|
||||
raise LoggingException(record)
|
||||
|
||||
def emit_pretty_exception(self, exc, verbose: bool = False):
|
||||
return exc.__pretty_exc__(verbose=verbose)
|
||||
|
||||
def emit(self, record):
|
||||
"""Write to Tqdm's stream so as to not break progress-bars"""
|
||||
try:
|
||||
if record.exc_info:
|
||||
_, exc, *_ = record.exc_info
|
||||
if hasattr(exc, "__pretty_exc__"):
|
||||
try:
|
||||
self.emit_pretty_exception(exc, verbose=_is_verbose())
|
||||
if not _is_verbose():
|
||||
return
|
||||
except Exception: # noqa, pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
msg = self.format(record)
|
||||
Tqdm.write(
|
||||
msg, file=self.stream, end=getattr(self, "terminator", "\n")
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Dict,
|
||||
Iterable,
|
||||
Mapping,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
)
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.types import StrPath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
from .backend.base import BaseMachineBackend
|
||||
|
||||
BackendCls = Type[BaseMachineBackend]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
RESERVED_NAMES = {"local", "localhost"}
|
||||
|
||||
DEFAULT_STARTUP_SCRIPT = """#!/bin/bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes python3 python3-pip
|
||||
python3 -m pip install --upgrade pip
|
||||
|
||||
pushd /etc/apt/sources.list.d
|
||||
sudo wget https://dvc.org/deb/dvc.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes dvc
|
||||
popd
|
||||
"""
|
||||
|
||||
|
||||
def validate_name(name: str):
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
|
||||
name = name.lower()
|
||||
if name in RESERVED_NAMES:
|
||||
raise InvalidArgumentError(
|
||||
f"Machine name '{name}' is reserved for internal DVC use."
|
||||
)
|
||||
|
||||
|
||||
class MachineBackends(Mapping):
|
||||
try:
|
||||
from .backend.terraform import TerraformBackend
|
||||
except ImportError:
|
||||
TerraformBackend = None # type: ignore[assignment, misc]
|
||||
|
||||
DEFAULT: Dict[str, Optional["BackendCls"]] = {
|
||||
"terraform": TerraformBackend,
|
||||
}
|
||||
|
||||
def __getitem__(self, key: str) -> "BaseMachineBackend":
|
||||
"""Lazily initialize backends and cache it afterwards"""
|
||||
initialized = self.initialized.get(key)
|
||||
if not initialized:
|
||||
backend = self.backends[key]
|
||||
initialized = backend(
|
||||
os.path.join(self.tmp_dir, key), **self.kwargs
|
||||
)
|
||||
self.initialized[key] = initialized
|
||||
return initialized
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
selected: Optional[Iterable[str]],
|
||||
tmp_dir: StrPath,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
selected = selected or list(self.DEFAULT)
|
||||
self.backends: Dict[str, "BackendCls"] = {}
|
||||
for key in selected:
|
||||
cls = self.DEFAULT.get(key)
|
||||
if cls is None:
|
||||
raise DvcException(
|
||||
f"'dvc machine' backend '{key}' is missing required "
|
||||
"dependencies. Install them with:\n"
|
||||
f"\tpip install dvc[{key}]"
|
||||
)
|
||||
self.backends[key] = cls
|
||||
|
||||
self.initialized: Dict[str, "BaseMachineBackend"] = {}
|
||||
|
||||
self.tmp_dir = tmp_dir
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.backends)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.backends)
|
||||
|
||||
def close_initialized(self) -> None:
|
||||
for backend in self.initialized.values():
|
||||
backend.close()
|
||||
|
||||
|
||||
class MachineManager:
|
||||
"""Class that manages dvc cloud machines.
|
||||
|
||||
Args:
|
||||
repo (dvc.repo.Repo): repo instance that belongs to the repo that
|
||||
we are working on.
|
||||
|
||||
Raises:
|
||||
config.ConfigError: thrown when config has invalid format.
|
||||
"""
|
||||
|
||||
CLOUD_BACKENDS = {
|
||||
"aws": "terraform",
|
||||
"azure": "terraform",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, repo: "Repo", backends: Optional[Iterable[str]] = None, **kwargs
|
||||
):
|
||||
self.repo = repo
|
||||
tmp_dir = os.path.join(self.repo.tmp_dir, "machine")
|
||||
self.backends = MachineBackends(backends, tmp_dir=tmp_dir, **kwargs)
|
||||
|
||||
def get_config_and_backend(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
) -> Tuple[dict, "BaseMachineBackend"]:
|
||||
from dvc.config import NoMachineError
|
||||
|
||||
if not name:
|
||||
name = self.repo.config["core"].get("machine")
|
||||
|
||||
if name:
|
||||
config = self._get_config(name=name)
|
||||
backend = self._get_backend(config["cloud"])
|
||||
return config, backend
|
||||
|
||||
if bool(self.repo.config["machine"]):
|
||||
error_msg = (
|
||||
"no machine specified. Setup default machine with\n"
|
||||
" dvc machine default <name>\n"
|
||||
)
|
||||
else:
|
||||
error_msg = (
|
||||
"no machine specified. Create a default machine with\n"
|
||||
" dvc machine add -d <name> <cloud>"
|
||||
)
|
||||
|
||||
raise NoMachineError(error_msg)
|
||||
|
||||
def _get_config(self, **kwargs):
|
||||
config = self.repo.config
|
||||
name = kwargs.get("name")
|
||||
if name:
|
||||
try:
|
||||
conf = config["machine"][name.lower()]
|
||||
conf["name"] = name
|
||||
except KeyError:
|
||||
from dvc.config import MachineNotFoundError
|
||||
|
||||
raise MachineNotFoundError(f"machine '{name}' doesn't exist")
|
||||
else:
|
||||
conf = kwargs
|
||||
return conf
|
||||
|
||||
def _get_backend(self, cloud: str) -> "BaseMachineBackend":
|
||||
from dvc.config import NoMachineError
|
||||
|
||||
try:
|
||||
backend = self.CLOUD_BACKENDS[cloud]
|
||||
return self.backends[backend]
|
||||
except KeyError:
|
||||
raise NoMachineError(f"Machine platform '{cloud}' unsupported")
|
||||
|
||||
def create(self, name: Optional[str]):
|
||||
"""Create and start the specified machine instance."""
|
||||
config, backend = self.get_config_and_backend(name)
|
||||
if "startup_script" in config:
|
||||
with open(config["startup_script"]) as fobj:
|
||||
startup_script = fobj.read()
|
||||
else:
|
||||
startup_script = DEFAULT_STARTUP_SCRIPT
|
||||
config["startup_script"] = startup_script
|
||||
return backend.create(**config)
|
||||
|
||||
def destroy(self, name: Optional[str]):
|
||||
"""Destroy the specified machine instance."""
|
||||
config, backend = self.get_config_and_backend(name)
|
||||
return backend.destroy(**config)
|
||||
|
||||
def get_sshfs(self, name: Optional[str]):
|
||||
config, backend = self.get_config_and_backend(name)
|
||||
return backend.get_sshfs(**config)
|
||||
|
||||
def run_shell(self, name: Optional[str]):
|
||||
config, backend = self.get_config_and_backend(name)
|
||||
return backend.run_shell(**config)
|
||||
@@ -0,0 +1,50 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Iterator, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.ssh import SSHFileSystem
|
||||
from dvc.repo.experiments.executor.base import BaseExecutor
|
||||
from dvc.types import StrPath
|
||||
|
||||
|
||||
class BaseMachineBackend(ABC):
|
||||
def __init__(self, tmp_dir: "StrPath", **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def create(self, name: Optional[str] = None, **config):
|
||||
"""Create and start an instance of the specified machine."""
|
||||
|
||||
@abstractmethod
|
||||
def destroy(self, name: Optional[str] = None, **config):
|
||||
"""Stop and destroy all instances of the specified machine."""
|
||||
|
||||
@abstractmethod
|
||||
def instances(
|
||||
self, name: Optional[str] = None, **config
|
||||
) -> Iterator[dict]:
|
||||
"""Iterate over status of all instances of the specified machine."""
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run_shell(self, name: Optional[str] = None, **config):
|
||||
"""Spawn an interactive SSH shell for the specified machine."""
|
||||
|
||||
@abstractmethod
|
||||
def get_executor(
|
||||
self, name: Optional[str] = None, **config
|
||||
) -> "BaseExecutor":
|
||||
"""Return an executor instance which can be used for DVC
|
||||
experiment/pipeline execution on the specified machine.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@contextmanager
|
||||
def get_sshfs(
|
||||
self, name: Optional[str] = None, **config
|
||||
) -> Iterator["SSHFileSystem"]:
|
||||
"""Return an sshfs instance for the default directory on the
|
||||
specified machine."""
|
||||
@@ -0,0 +1,35 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Iterator, Optional
|
||||
|
||||
from tpi import TerraformProviderIterative, terraform
|
||||
|
||||
from dvc.fs.ssh import SSHFileSystem
|
||||
|
||||
from .base import BaseMachineBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo.experiments.executor.base import BaseExecutor
|
||||
from dvc.types import StrPath
|
||||
|
||||
|
||||
class TerraformBackend(terraform.TerraformBackend, BaseMachineBackend):
|
||||
def __init__(self, tmp_dir: "StrPath", **kwargs):
|
||||
super().__init__(tmp_dir)
|
||||
|
||||
def get_executor(
|
||||
self, name: Optional[str] = None, **config
|
||||
) -> "BaseExecutor":
|
||||
raise NotImplementedError
|
||||
|
||||
@contextmanager
|
||||
def get_sshfs( # pylint: disable=unused-argument
|
||||
self, name: Optional[str] = None, **config
|
||||
) -> Iterator["SSHFileSystem"]:
|
||||
resource = self._default_resource(name)
|
||||
with TerraformProviderIterative.pemfile(resource) as pem:
|
||||
fs = SSHFileSystem(
|
||||
host=resource["instance_ip"],
|
||||
user="ubuntu",
|
||||
keyfile=pem,
|
||||
)
|
||||
yield fs
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from shortuuid import uuid
|
||||
|
||||
@@ -10,52 +11,19 @@ from dvc.exceptions import (
|
||||
DvcException,
|
||||
)
|
||||
from dvc.ignore import DvcIgnoreFilter
|
||||
from dvc.objects import check, load
|
||||
from dvc.objects.db.slow_link_detection import ( # type: ignore[attr-defined]
|
||||
slow_link_guard,
|
||||
)
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.diff import ROOT
|
||||
from dvc.objects.diff import diff as odiff
|
||||
from dvc.objects.stage import stage
|
||||
from dvc.objects.tree import Tree
|
||||
from dvc.types import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _changed(path_info, fs, obj, cache, state=None):
|
||||
logger.trace("checking if '%s'('%s') has changed.", path_info, obj)
|
||||
|
||||
try:
|
||||
check(cache, obj)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
logger.debug(
|
||||
"cache for '%s'('%s') has changed.", path_info, obj.hash_info
|
||||
)
|
||||
return True
|
||||
|
||||
actual = state.get(path_info, fs) if state else None
|
||||
if actual is None:
|
||||
try:
|
||||
actual = stage(
|
||||
cache, path_info, fs, obj.hash_info.name, dry_run=True
|
||||
)[1].hash_info
|
||||
except FileNotFoundError:
|
||||
logger.debug("'%s' doesn't exist.", path_info)
|
||||
return True
|
||||
|
||||
if obj.hash_info != actual:
|
||||
logger.debug(
|
||||
"hash value '%s' for '%s' has changed (actual '%s').",
|
||||
obj.hash_info,
|
||||
actual,
|
||||
path_info,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.trace("'%s' hasn't changed.", path_info)
|
||||
return False
|
||||
|
||||
|
||||
def _remove(path_info, fs, cache, force=False):
|
||||
def _remove(path_info, fs, in_cache, force=False):
|
||||
if not fs.exists(path_info):
|
||||
return
|
||||
|
||||
@@ -63,13 +31,7 @@ def _remove(path_info, fs, cache, force=False):
|
||||
fs.remove(path_info)
|
||||
return
|
||||
|
||||
current = stage(cache, path_info, fs, fs.PARAM_CHECKSUM, dry_run=True)[
|
||||
1
|
||||
].hash_info
|
||||
try:
|
||||
obj = load(cache, current)
|
||||
check(cache, obj)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
if not in_cache:
|
||||
msg = (
|
||||
f"file/directory '{path_info}' is going to be removed. "
|
||||
"Are you sure you want to proceed?"
|
||||
@@ -98,7 +60,7 @@ def _verify_link(cache, path_info, link_type):
|
||||
|
||||
def _do_link(cache, from_info, to_info, link_method):
|
||||
if cache.fs.exists(to_info):
|
||||
raise DvcException(f"Link '{to_info}' already exists!")
|
||||
cache.fs.remove(to_info) # broken symlink
|
||||
|
||||
link_method(from_info, to_info)
|
||||
|
||||
@@ -130,7 +92,7 @@ def _link(cache, from_info, to_info):
|
||||
try:
|
||||
_try_links(cache, from_info, to_info, cache.cache_types)
|
||||
except FileNotFoundError as exc:
|
||||
raise CheckoutError([to_info]) from exc
|
||||
raise CheckoutError([str(to_info)]) from exc
|
||||
|
||||
|
||||
def _cache_is_copy(cache, path_info):
|
||||
@@ -160,7 +122,7 @@ def _cache_is_copy(cache, path_info):
|
||||
def _checkout_file(
|
||||
path_info,
|
||||
fs,
|
||||
obj,
|
||||
change,
|
||||
cache,
|
||||
force,
|
||||
progress_callback=None,
|
||||
@@ -169,24 +131,24 @@ def _checkout_file(
|
||||
):
|
||||
"""The file is changed we need to checkout a new copy"""
|
||||
modified = False
|
||||
cache_info = cache.hash_to_path_info(obj.hash_info.value)
|
||||
if fs.exists(path_info):
|
||||
if not relink and _changed(path_info, fs, obj, cache, state=state):
|
||||
modified = True
|
||||
_remove(path_info, fs, cache, force=force)
|
||||
_link(cache, cache_info, path_info)
|
||||
else:
|
||||
cache_info = cache.hash_to_path_info(change.new.obj.hash_info.value)
|
||||
if change.old.obj:
|
||||
if relink:
|
||||
if fs.iscopy(path_info) and _cache_is_copy(cache, path_info):
|
||||
cache.unprotect(path_info)
|
||||
else:
|
||||
_remove(path_info, fs, cache, force=force)
|
||||
_remove(path_info, fs, change.old.in_cache, force=force)
|
||||
_link(cache, cache_info, path_info)
|
||||
else:
|
||||
modified = True
|
||||
_remove(path_info, fs, change.old.in_cache, force=force)
|
||||
_link(cache, cache_info, path_info)
|
||||
else:
|
||||
_link(cache, cache_info, path_info)
|
||||
modified = True
|
||||
|
||||
if state:
|
||||
state.save(path_info, fs, obj.hash_info)
|
||||
state.save(path_info, fs, change.new.obj.hash_info)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(str(path_info))
|
||||
@@ -194,109 +156,83 @@ def _checkout_file(
|
||||
return modified
|
||||
|
||||
|
||||
def _remove_redundant_files(
|
||||
path_info, fs, obj, cache, force, dvcignore: Optional[DvcIgnoreFilter]
|
||||
):
|
||||
if dvcignore:
|
||||
existing_files = set(dvcignore.walk_files(fs, path_info))
|
||||
else:
|
||||
existing_files = set(fs.walk_files(path_info))
|
||||
|
||||
needed_files = {path_info.joinpath(*key) for key, _ in obj}
|
||||
redundant_files = existing_files - needed_files
|
||||
for path in redundant_files:
|
||||
_remove(path, fs, cache, force)
|
||||
|
||||
return bool(redundant_files)
|
||||
|
||||
|
||||
def _checkout_dir(
|
||||
def _diff(
|
||||
path_info,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
force,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
dvcignore: Optional[DvcIgnoreFilter] = None,
|
||||
state=None,
|
||||
):
|
||||
modified = False
|
||||
# Create dir separately so that dir is created
|
||||
# even if there are no files in it
|
||||
if not fs.exists(path_info):
|
||||
modified = True
|
||||
fs.makedirs(path_info)
|
||||
|
||||
logger.debug("Linking directory '%s'.", path_info)
|
||||
|
||||
failed = []
|
||||
for entry_key, entry_obj in obj:
|
||||
try:
|
||||
entry_modified = _checkout_file(
|
||||
path_info.joinpath(*entry_key),
|
||||
fs,
|
||||
entry_obj,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
state=None,
|
||||
)
|
||||
if entry_modified:
|
||||
modified = True
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
modified = (
|
||||
_remove_redundant_files(
|
||||
path_info, fs, obj, cache, force, dvcignore=dvcignore
|
||||
old = None
|
||||
try:
|
||||
_, old = stage(
|
||||
cache,
|
||||
path_info,
|
||||
fs,
|
||||
obj.hash_info.name if obj else cache.fs.PARAM_CHECKSUM,
|
||||
dry_run=True,
|
||||
dvcignore=dvcignore,
|
||||
)
|
||||
or modified
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
diff = odiff(old, obj, cache)
|
||||
|
||||
if state:
|
||||
state.save(path_info, fs, obj.hash_info)
|
||||
if relink:
|
||||
diff.modified.extend(diff.unchanged)
|
||||
|
||||
# relink is not modified, assume it as nochange
|
||||
return modified and not relink
|
||||
return diff
|
||||
|
||||
|
||||
def _checkout(
|
||||
diff,
|
||||
path_info,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
force=False,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
dvcignore: Optional[DvcIgnoreFilter] = None,
|
||||
state=None,
|
||||
):
|
||||
if not obj.hash_info.isdir:
|
||||
ret = _checkout_file(
|
||||
path_info, fs, obj, cache, force, progress_callback, relink, state
|
||||
)
|
||||
else:
|
||||
ret = _checkout_dir(
|
||||
path_info,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
dvcignore=dvcignore,
|
||||
state=state,
|
||||
)
|
||||
if not diff:
|
||||
return
|
||||
|
||||
if state:
|
||||
state.save_link(path_info, fs)
|
||||
for change in diff.deleted:
|
||||
entry_path = (
|
||||
path_info.joinpath(*change.old.key)
|
||||
if change.old.key != ROOT
|
||||
else path_info
|
||||
)
|
||||
_remove(entry_path, fs, change.old.in_cache, force=force)
|
||||
|
||||
return ret
|
||||
failed = []
|
||||
for change in chain(diff.added, diff.modified):
|
||||
entry_path = (
|
||||
path_info.joinpath(*change.new.key)
|
||||
if change.new.key != ROOT
|
||||
else path_info
|
||||
)
|
||||
if isinstance(change.new.obj, Tree):
|
||||
fs.makedirs(entry_path)
|
||||
continue
|
||||
|
||||
try:
|
||||
_checkout_file(
|
||||
entry_path,
|
||||
fs,
|
||||
change,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
state=state,
|
||||
)
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
|
||||
|
||||
def checkout(
|
||||
@@ -311,41 +247,54 @@ def checkout(
|
||||
dvcignore: Optional[DvcIgnoreFilter] = None,
|
||||
state=None,
|
||||
):
|
||||
|
||||
if path_info.scheme not in ["local", cache.fs.scheme]:
|
||||
raise NotImplementedError
|
||||
|
||||
failed = None
|
||||
skip = False
|
||||
diff = _diff(
|
||||
path_info,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
relink=relink,
|
||||
dvcignore=dvcignore,
|
||||
)
|
||||
|
||||
failed = []
|
||||
if not obj:
|
||||
if not quiet:
|
||||
logger.warning(
|
||||
"No file hash info found for '%s'. It won't be created.",
|
||||
path_info,
|
||||
)
|
||||
_remove(path_info, fs, cache, force=force)
|
||||
failed = path_info
|
||||
|
||||
elif not relink and not _changed(path_info, fs, obj, cache, state=state):
|
||||
failed.append(str(path_info))
|
||||
elif not diff:
|
||||
logger.trace("Data '%s' didn't change.", path_info) # type: ignore
|
||||
skip = True
|
||||
|
||||
if failed or skip:
|
||||
try:
|
||||
_checkout(
|
||||
diff,
|
||||
path_info,
|
||||
fs,
|
||||
cache,
|
||||
force=force,
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
state=state,
|
||||
)
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
if diff and state:
|
||||
state.save_link(path_info, fs)
|
||||
if not failed:
|
||||
state.save(path_info, fs, obj.hash_info)
|
||||
|
||||
if failed or not diff:
|
||||
if progress_callback and obj:
|
||||
progress_callback(str(path_info), len(obj))
|
||||
if failed:
|
||||
raise CheckoutError([failed])
|
||||
raise CheckoutError(failed)
|
||||
return
|
||||
|
||||
logger.debug("Checking out '%s' with cache '%s'.", path_info, obj)
|
||||
|
||||
return _checkout(
|
||||
path_info,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
dvcignore=dvcignore,
|
||||
state=state,
|
||||
)
|
||||
return bool(diff) and not relink
|
||||
+6
-39
@@ -1,12 +1,10 @@
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.objects.errors import ObjectDBPermissionError, ObjectFormatError
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.progress import Tqdm
|
||||
@@ -85,38 +83,12 @@ class ObjectDB:
|
||||
_hash_info: "HashInfo",
|
||||
move: bool = False,
|
||||
):
|
||||
from dvc import fs
|
||||
|
||||
self.makedirs(to_info.parent)
|
||||
use_move = isinstance(from_fs, type(self.fs)) and move
|
||||
try:
|
||||
if use_move:
|
||||
self.fs.move(from_info, to_info)
|
||||
elif isinstance(from_fs, LocalFileSystem):
|
||||
if not isinstance(from_info, from_fs.PATH_CLS):
|
||||
from_info = from_fs.PATH_CLS(from_info)
|
||||
self.fs.upload(from_info, to_info)
|
||||
elif isinstance(self.fs, LocalFileSystem):
|
||||
from_fs.download_file(from_info, to_info)
|
||||
else:
|
||||
with from_fs.open(from_info, mode="rb") as fobj:
|
||||
self.fs.upload_fobj(fobj, to_info)
|
||||
except OSError as exc:
|
||||
# If the target file already exists, we are going to simply
|
||||
# ignore the exception (#4992).
|
||||
#
|
||||
# On Windows, it is not always guaranteed that you'll get
|
||||
# FileExistsError (it might be PermissionError or a bare OSError)
|
||||
# but all of those exceptions raised from the original
|
||||
# FileExistsError so we have a separate check for that.
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", to_info)
|
||||
if use_move:
|
||||
from_fs.remove(from_info)
|
||||
else:
|
||||
raise
|
||||
return fs.utils.transfer(
|
||||
from_fs, from_info, self.fs, to_info, move=move
|
||||
)
|
||||
|
||||
def add(
|
||||
self,
|
||||
@@ -125,7 +97,6 @@ class ObjectDB:
|
||||
hash_info: "HashInfo",
|
||||
move: bool = True,
|
||||
verify: Optional[bool] = None,
|
||||
**kwargs,
|
||||
):
|
||||
if self.read_only:
|
||||
raise ObjectDBPermissionError("Cannot add to read-only ODB")
|
||||
@@ -139,7 +110,7 @@ class ObjectDB:
|
||||
pass
|
||||
|
||||
cache_info = self.hash_to_path_info(hash_info.value)
|
||||
self._add_file(fs, path_info, cache_info, hash_info, move)
|
||||
self._add_file(fs, path_info, cache_info, hash_info, move=move)
|
||||
|
||||
try:
|
||||
if verify:
|
||||
@@ -149,10 +120,6 @@ class ObjectDB:
|
||||
except (ObjectFormatError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
callback = kwargs.get("download_callback")
|
||||
if callback:
|
||||
callback(1)
|
||||
|
||||
def hash_to_path_info(self, hash_) -> "DvcPath":
|
||||
return self.path_info / hash_[0:2] / hash_[2:]
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class ReferenceObjectDB(ObjectDB):
|
||||
ref_fobj = io.BytesIO(ref_file.to_bytes())
|
||||
ref_fobj.seek(0)
|
||||
try:
|
||||
self.fs.upload_fobj(ref_fobj, to_info)
|
||||
self.fs.upload(ref_fobj, to_info)
|
||||
except OSError as exc:
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .file import HashFile
|
||||
|
||||
ADD = "add"
|
||||
MODIFY = "modify"
|
||||
DELETE = "delete"
|
||||
UNCHANGED = "unchanged"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeEntry:
|
||||
in_cache: bool
|
||||
key: Tuple[str]
|
||||
obj: Optional["HashFile"] = field(default=None)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.obj)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, TreeEntry):
|
||||
return False
|
||||
|
||||
if self.key != other.key or bool(self.obj) != bool(other.obj):
|
||||
return False
|
||||
|
||||
if not self.obj:
|
||||
return False
|
||||
|
||||
return self.obj.hash_info == other.obj.hash_info
|
||||
|
||||
|
||||
@dataclass
|
||||
class Change:
|
||||
old: TreeEntry
|
||||
new: TreeEntry
|
||||
|
||||
@property
|
||||
def typ(self):
|
||||
if not self.old and not self.new:
|
||||
return UNCHANGED
|
||||
|
||||
if self.old and not self.new:
|
||||
return DELETE
|
||||
|
||||
if not self.old and self.new:
|
||||
return ADD
|
||||
|
||||
if self.old != self.new:
|
||||
return MODIFY
|
||||
|
||||
return UNCHANGED
|
||||
|
||||
def __bool__(self):
|
||||
return self.typ != UNCHANGED
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffResult:
|
||||
added: List[Change] = field(default_factory=list, compare=True)
|
||||
modified: List[Change] = field(default_factory=list, compare=True)
|
||||
deleted: List[Change] = field(default_factory=list, compare=True)
|
||||
unchanged: List[Change] = field(default_factory=list, compare=True)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.added or self.modified or self.deleted)
|
||||
|
||||
|
||||
ROOT = ("",)
|
||||
|
||||
|
||||
def diff(
|
||||
old: Optional["HashFile"], new: Optional["HashFile"], cache
|
||||
) -> DiffResult:
|
||||
from .tree import Tree
|
||||
|
||||
if old is None and new is None:
|
||||
return DiffResult()
|
||||
|
||||
def _get_keys(obj):
|
||||
if not obj:
|
||||
return []
|
||||
return [ROOT] + (
|
||||
[key for key, _ in obj] if isinstance(obj, Tree) else []
|
||||
)
|
||||
|
||||
old_keys = set(_get_keys(old))
|
||||
new_keys = set(_get_keys(new))
|
||||
|
||||
def _get_obj(obj, key):
|
||||
if not obj or key == ROOT:
|
||||
return obj
|
||||
|
||||
return obj.get(key)
|
||||
|
||||
def _in_cache(obj, cache):
|
||||
from . import check
|
||||
from .errors import ObjectFormatError
|
||||
|
||||
if not obj:
|
||||
return False
|
||||
|
||||
try:
|
||||
check(cache, obj)
|
||||
return True
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
return False
|
||||
|
||||
ret = DiffResult()
|
||||
for key in old_keys | new_keys:
|
||||
old_obj = _get_obj(old, key)
|
||||
new_obj = _get_obj(new, key)
|
||||
|
||||
change = Change(
|
||||
old=TreeEntry(_in_cache(old_obj, cache), key, old_obj),
|
||||
new=TreeEntry(_in_cache(new_obj, cache), key, new_obj),
|
||||
)
|
||||
|
||||
if change.typ == ADD:
|
||||
ret.added.append(change)
|
||||
elif change.typ == MODIFY:
|
||||
ret.modified.append(change)
|
||||
elif change.typ == DELETE:
|
||||
ret.deleted.append(change)
|
||||
else:
|
||||
assert change.typ == UNCHANGED
|
||||
if not change.new.in_cache and not isinstance(
|
||||
change.new.obj, Tree
|
||||
):
|
||||
ret.modified.append(change)
|
||||
else:
|
||||
ret.unchanged.append(change)
|
||||
return ret
|
||||
@@ -2,7 +2,7 @@ import errno
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from .errors import ObjectFormatError
|
||||
from .file import HashFile
|
||||
@@ -20,8 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
class ReferenceHashFile(HashFile):
|
||||
PARAM_PATH = "path"
|
||||
PARAM_HASH = "hash"
|
||||
PARAM_MTIME = "mtime"
|
||||
PARAM_SIZE = "size"
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
PARAM_FS_CONFIG = "fs_config"
|
||||
|
||||
def __init__(
|
||||
@@ -29,14 +28,11 @@ class ReferenceHashFile(HashFile):
|
||||
path_info: "AnyPath",
|
||||
fs: "BaseFileSystem",
|
||||
hash_info: "HashInfo",
|
||||
mtime: Optional[str] = None,
|
||||
size: Optional[int] = None,
|
||||
checksum: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(path_info, fs, hash_info, **kwargs)
|
||||
cur_mtime, cur_size = self._get_mtime_and_size()
|
||||
self.mtime = cur_mtime if mtime is None else mtime
|
||||
self.hash_info.size = cur_size if size is None else size
|
||||
self.checksum = checksum or fs.checksum(path_info)
|
||||
|
||||
def __str__(self):
|
||||
return f"ref object {self.hash_info} -> {self.path_info}"
|
||||
@@ -47,21 +43,14 @@ class ReferenceHashFile(HashFile):
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), self.path_info
|
||||
)
|
||||
if not (self.mtime, self.size) == self._get_mtime_and_size():
|
||||
if self.checksum != self._get_checksum():
|
||||
raise ObjectFormatError(f"{self} is changed")
|
||||
if check_hash:
|
||||
self._check_hash(odb)
|
||||
|
||||
def _get_mtime_and_size(self) -> Tuple[Optional[str], int]:
|
||||
from dvc.utils.fs import get_mtime_and_size
|
||||
|
||||
def _get_checksum(self) -> str:
|
||||
assert self.fs
|
||||
if hasattr(self.fs, "stat"):
|
||||
try:
|
||||
return get_mtime_and_size(self.path_info, self.fs)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return None, self.fs.getsize(self.path_info)
|
||||
return self.fs.checksum(self.path_info)
|
||||
|
||||
def to_bytes(self):
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
@@ -79,8 +68,7 @@ class ReferenceHashFile(HashFile):
|
||||
dict_ = {
|
||||
self.PARAM_PATH: path_info,
|
||||
self.PARAM_HASH: self.hash_info,
|
||||
self.PARAM_MTIME: self.mtime,
|
||||
self.PARAM_SIZE: self.size,
|
||||
self.PARAM_CHECKSUM: self.checksum,
|
||||
self.PARAM_FS_CONFIG: self.config_tuple(self.fs),
|
||||
}
|
||||
try:
|
||||
@@ -118,8 +106,7 @@ class ReferenceHashFile(HashFile):
|
||||
path_info,
|
||||
fs,
|
||||
hash_info,
|
||||
mtime=dict_.get(cls.PARAM_MTIME),
|
||||
size=dict_.get(cls.PARAM_SIZE),
|
||||
checksum=dict_.get(cls.PARAM_CHECKSUM),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
+11
-14
@@ -34,9 +34,8 @@ def _upload_file(path_info, fs, odb, upload_odb):
|
||||
tmp_info = upload_odb.path_info / tmp_fname()
|
||||
with fs.open(path_info, mode="rb", chunk_size=fs.CHUNK_SIZE) as stream:
|
||||
stream = HashedStreamReader(stream)
|
||||
upload_odb.fs.upload_fobj(
|
||||
stream, tmp_info, desc=path_info.name, size=fs.getsize(path_info)
|
||||
)
|
||||
size = fs.getsize(path_info)
|
||||
upload_odb.fs.upload(stream, tmp_info, desc=path_info.name, total=size)
|
||||
|
||||
odb.add(tmp_info, upload_odb.fs, stream.hash_info)
|
||||
return path_info, odb.get(stream.hash_info)
|
||||
@@ -46,18 +45,16 @@ def _get_file_hash(path_info, fs, name):
|
||||
info = fs.info(path_info)
|
||||
if name in info:
|
||||
assert not info[name].endswith(".dir")
|
||||
return HashInfo(name, info[name], size=info["size"])
|
||||
hash_value = info[name]
|
||||
elif hasattr(fs, name):
|
||||
func = getattr(fs, name)
|
||||
hash_value = func(path_info)
|
||||
elif name == "md5":
|
||||
hash_value = file_md5(path_info, fs)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
func = getattr(fs, name, None)
|
||||
if func:
|
||||
return func(path_info)
|
||||
|
||||
if name == "md5":
|
||||
return HashInfo(
|
||||
name, file_md5(path_info, fs), size=fs.getsize(path_info)
|
||||
)
|
||||
|
||||
raise NotImplementedError
|
||||
return HashInfo(name, hash_value, size=info["size"])
|
||||
|
||||
|
||||
def get_file_hash(path_info, fs, name, state=None):
|
||||
|
||||
+40
-107
@@ -1,20 +1,19 @@
|
||||
import errno
|
||||
import itertools
|
||||
import logging
|
||||
from concurrent import futures
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial, wraps
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, Optional
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional
|
||||
|
||||
from funcy import split
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils.threadpool import ThreadPoolExecutor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
from .db.base import ObjectDB
|
||||
from .db.index import ObjectDBIndexBase
|
||||
from .tree import Tree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,109 +37,48 @@ def _log_exceptions(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _transfer(
|
||||
src, dest, dir_ids, file_ids, missing_ids, jobs, verify, move, **kwargs
|
||||
):
|
||||
func = _log_exceptions(dest.add)
|
||||
total = len(dir_ids) + len(file_ids)
|
||||
if total == 0:
|
||||
return 0
|
||||
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
|
||||
func = pbar.wrap_fn(func)
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
processor = partial(
|
||||
_create_tasks,
|
||||
executor,
|
||||
jobs,
|
||||
func,
|
||||
src,
|
||||
verify,
|
||||
move,
|
||||
)
|
||||
processor.add_func = func
|
||||
_do_transfer(
|
||||
src,
|
||||
dest,
|
||||
dir_ids,
|
||||
file_ids,
|
||||
missing_ids,
|
||||
processor,
|
||||
verify=verify,
|
||||
move=move,
|
||||
**kwargs,
|
||||
)
|
||||
return total
|
||||
def find_tree_by_obj_id(
|
||||
odbs: Iterable[Optional["ObjectDB"]], obj_id: "HashInfo"
|
||||
) -> Optional["Tree"]:
|
||||
from .errors import ObjectFormatError
|
||||
from .tree import Tree
|
||||
|
||||
|
||||
def _create_tasks(executor, jobs, func, src, verify, move, obj_ids):
|
||||
fails = 0
|
||||
hash_iter = iter(obj_ids)
|
||||
|
||||
def submit(hash_info):
|
||||
obj = src.get(hash_info)
|
||||
return executor.submit(
|
||||
func,
|
||||
obj.path_info,
|
||||
obj.fs,
|
||||
obj.hash_info,
|
||||
verify=verify,
|
||||
move=move,
|
||||
)
|
||||
|
||||
def create_taskset(amount):
|
||||
return {
|
||||
submit(hash_info)
|
||||
for hash_info in itertools.islice(hash_iter, amount)
|
||||
}
|
||||
|
||||
tasks = create_taskset(jobs * 5)
|
||||
while tasks:
|
||||
done, tasks = futures.wait(tasks, return_when=futures.FIRST_COMPLETED)
|
||||
fails += sum(task.result() for task in done)
|
||||
tasks.update(create_taskset(len(done)))
|
||||
return fails
|
||||
for odb in odbs:
|
||||
if odb is not None:
|
||||
try:
|
||||
return Tree.load(odb, obj_id)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _do_transfer(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
dir_ids: Iterable["HashInfo"],
|
||||
file_ids: Iterable["HashInfo"],
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
missing_ids: Iterable["HashInfo"],
|
||||
processor: Callable,
|
||||
src_index: Optional["ObjectDBIndexBase"] = None,
|
||||
dest_index: Optional["ObjectDBIndexBase"] = None,
|
||||
cache_odb: Optional["ObjectDB"] = None,
|
||||
**kwargs,
|
||||
**kwargs: Any,
|
||||
):
|
||||
from dvc.exceptions import FileTransferError
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, obj_ids)
|
||||
total_fails = 0
|
||||
succeeded_dir_objs = []
|
||||
all_file_ids = set(file_ids)
|
||||
|
||||
for dir_hash in dir_ids:
|
||||
from .tree import Tree
|
||||
|
||||
bound_file_ids = set()
|
||||
dir_obj: Optional["Tree"] = None
|
||||
for odb in (cache_odb, src):
|
||||
if odb is not None:
|
||||
try:
|
||||
dir_obj = Tree.load(odb, dir_hash)
|
||||
break
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
dir_obj = find_tree_by_obj_id([cache_odb, src], dir_hash)
|
||||
assert dir_obj
|
||||
|
||||
entry_ids = {entry.hash_info for _, entry in dir_obj}
|
||||
bound_file_ids = all_file_ids & entry_ids
|
||||
all_file_ids -= entry_ids
|
||||
|
||||
for file_hash in all_file_ids.copy():
|
||||
if file_hash in entry_ids:
|
||||
bound_file_ids.add(file_hash)
|
||||
all_file_ids.remove(file_hash)
|
||||
|
||||
dir_fails = processor(bound_file_ids)
|
||||
dir_fails = sum(processor(bound_file_ids))
|
||||
if dir_fails:
|
||||
logger.debug(
|
||||
"failed to upload full contents of '%s', "
|
||||
@@ -164,19 +102,13 @@ def _do_transfer(
|
||||
dir_obj.name,
|
||||
)
|
||||
else:
|
||||
raw_obj = src.get(dir_obj.hash_info)
|
||||
is_dir_failed = processor.add_func( # type: ignore[attr-defined]
|
||||
raw_obj.path_info,
|
||||
raw_obj.fs,
|
||||
raw_obj.hash_info,
|
||||
**kwargs,
|
||||
)
|
||||
is_dir_failed = sum(processor([dir_obj.hash_info]))
|
||||
total_fails += is_dir_failed
|
||||
if not is_dir_failed:
|
||||
succeeded_dir_objs.append(dir_obj)
|
||||
|
||||
# insert the rest
|
||||
total_fails += processor(all_file_ids)
|
||||
total_fails += sum(processor(all_file_ids))
|
||||
if total_fails:
|
||||
if src_index:
|
||||
src_index.clear()
|
||||
@@ -222,18 +154,19 @@ def transfer(
|
||||
if not status.new:
|
||||
return 0
|
||||
|
||||
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, status.new)
|
||||
if jobs is None:
|
||||
jobs = dest.fs.jobs
|
||||
def func(hash_info: "HashInfo") -> None:
|
||||
obj = src.get(hash_info)
|
||||
return dest.add(
|
||||
obj.path_info, obj.fs, obj.hash_info, verify=verify, move=move
|
||||
)
|
||||
|
||||
return _transfer(
|
||||
src,
|
||||
dest,
|
||||
set(dir_ids),
|
||||
set(file_ids),
|
||||
status.missing,
|
||||
jobs,
|
||||
verify,
|
||||
move,
|
||||
**kwargs,
|
||||
)
|
||||
total = len(status.new)
|
||||
jobs = jobs or dest.fs.jobs
|
||||
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
wrapped_func = pbar.wrap_fn(_log_exceptions(func))
|
||||
processor = partial(executor.imap_unordered, wrapped_func)
|
||||
_do_transfer(
|
||||
src, dest, status.new, status.missing, processor, **kwargs
|
||||
)
|
||||
return total
|
||||
|
||||
+31
-13
@@ -9,7 +9,6 @@ from funcy import collecting, project
|
||||
from voluptuous import And, Any, Coerce, Length, Lower, Required, SetTo
|
||||
|
||||
from dvc import objects, prompt
|
||||
from dvc.checkout import checkout
|
||||
from dvc.exceptions import (
|
||||
CheckoutError,
|
||||
CollectCacheError,
|
||||
@@ -17,6 +16,7 @@ from dvc.exceptions import (
|
||||
MergeError,
|
||||
RemoteCacheRequiredError,
|
||||
)
|
||||
from dvc.objects.checkout import checkout
|
||||
|
||||
from .fs import get_cloud_fs
|
||||
from .fs.hdfs import HDFSFileSystem
|
||||
@@ -81,6 +81,7 @@ def loadd_from(stage, d_list):
|
||||
desc = d.pop(Output.PARAM_DESC, False)
|
||||
isexec = d.pop(Output.PARAM_ISEXEC, False)
|
||||
live = d.pop(Output.PARAM_LIVE, False)
|
||||
remote = d.pop(Output.PARAM_REMOTE, None)
|
||||
ret.append(
|
||||
_get(
|
||||
stage,
|
||||
@@ -94,6 +95,7 @@ def loadd_from(stage, d_list):
|
||||
desc=desc,
|
||||
isexec=isexec,
|
||||
live=live,
|
||||
remote=remote,
|
||||
)
|
||||
)
|
||||
return ret
|
||||
@@ -109,6 +111,7 @@ def loads_from(
|
||||
checkpoint=False,
|
||||
isexec=False,
|
||||
live=False,
|
||||
remote=None,
|
||||
):
|
||||
return [
|
||||
_get(
|
||||
@@ -122,6 +125,7 @@ def loads_from(
|
||||
checkpoint=checkpoint,
|
||||
isexec=isexec,
|
||||
live=live,
|
||||
remote=remote,
|
||||
)
|
||||
for s in s_list
|
||||
]
|
||||
@@ -162,7 +166,6 @@ def load_from_pipeline(stage, data, typ="outs"):
|
||||
metric = typ == stage.PARAM_METRICS
|
||||
plot = typ == stage.PARAM_PLOTS
|
||||
live = typ == stage.PARAM_LIVE
|
||||
|
||||
if live:
|
||||
# `live` is single object
|
||||
data = [data]
|
||||
@@ -185,6 +188,7 @@ def load_from_pipeline(stage, data, typ="outs"):
|
||||
Output.PARAM_CACHE,
|
||||
Output.PARAM_PERSIST,
|
||||
Output.PARAM_CHECKPOINT,
|
||||
Output.PARAM_REMOTE,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -255,6 +259,7 @@ class Output:
|
||||
PARAM_LIVE = "live"
|
||||
PARAM_LIVE_SUMMARY = "summary"
|
||||
PARAM_LIVE_HTML = "html"
|
||||
PARAM_REMOTE = "remote"
|
||||
|
||||
METRIC_SCHEMA = Any(
|
||||
None,
|
||||
@@ -283,6 +288,7 @@ class Output:
|
||||
live=False,
|
||||
desc=None,
|
||||
isexec=False,
|
||||
remote=None,
|
||||
):
|
||||
self.repo = stage.repo if stage else None
|
||||
|
||||
@@ -326,7 +332,7 @@ class Output:
|
||||
self.obj = None
|
||||
self.isexec = False if self.IS_DEPENDENCY else isexec
|
||||
|
||||
self.def_remote = None
|
||||
self.remote = remote
|
||||
|
||||
def _parse_path(self, fs, path_info):
|
||||
if fs.scheme != "local":
|
||||
@@ -843,6 +849,8 @@ class Output:
|
||||
try:
|
||||
objects.check(self.odb, obj)
|
||||
except FileNotFoundError:
|
||||
if self.remote:
|
||||
kwargs["remote"] = self.remote
|
||||
self.repo.cloud.pull([obj.hash_info], **kwargs)
|
||||
|
||||
if self.obj:
|
||||
@@ -855,9 +863,9 @@ class Output:
|
||||
|
||||
return self.obj
|
||||
|
||||
def collect_used_dir_cache(
|
||||
def _collect_used_dir_cache(
|
||||
self, remote=None, force=False, jobs=None, filter_info=None
|
||||
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
|
||||
) -> Optional["Tree"]:
|
||||
"""Fetch dir cache and return used object IDs for this out."""
|
||||
|
||||
try:
|
||||
@@ -878,13 +886,13 @@ class Output:
|
||||
"unable to fully collect used cache"
|
||||
" without cache for directory '{}'".format(self)
|
||||
)
|
||||
return {}
|
||||
return None
|
||||
|
||||
obj = self.get_obj()
|
||||
if filter_info and filter_info != self.path_info:
|
||||
prefix = filter_info.relative_to(self.path_info).parts
|
||||
obj = obj.filter(prefix)
|
||||
return {None: set(self._named_obj_ids(obj))}
|
||||
return obj
|
||||
|
||||
def get_used_objs(
|
||||
self, **kwargs
|
||||
@@ -917,22 +925,31 @@ class Output:
|
||||
return {}
|
||||
|
||||
if self.is_dir_checksum:
|
||||
return self.collect_used_dir_cache(**kwargs)
|
||||
obj = self._collect_used_dir_cache(**kwargs)
|
||||
else:
|
||||
obj = self.get_obj(filter_info=kwargs.get("filter_info"))
|
||||
if not obj:
|
||||
obj = self.odb.get(self.hash_info)
|
||||
|
||||
obj = self.get_obj(filter_info=kwargs.get("filter_info"))
|
||||
if not obj:
|
||||
obj = self.odb.get(self.hash_info)
|
||||
return {}
|
||||
|
||||
return {None: set(self._named_obj_ids(obj))}
|
||||
if self.remote:
|
||||
remote = self.repo.cloud.get_remote_odb(name=self.remote)
|
||||
else:
|
||||
remote = None
|
||||
|
||||
return {remote: self._named_obj_ids(obj)}
|
||||
|
||||
def _named_obj_ids(self, obj):
|
||||
name = str(self)
|
||||
obj.hash_info.obj_name = name
|
||||
yield obj.hash_info
|
||||
oids = {obj.hash_info}
|
||||
if isinstance(obj, Tree):
|
||||
for key, entry_obj in obj:
|
||||
entry_obj.hash_info.obj_name = self.fs.sep.join([name, *key])
|
||||
yield entry_obj.hash_info
|
||||
oids.add(entry_obj.hash_info)
|
||||
return oids
|
||||
|
||||
def get_used_external(
|
||||
self, **kwargs
|
||||
@@ -1033,4 +1050,5 @@ SCHEMA = {
|
||||
Output.PARAM_CACHE: bool,
|
||||
Output.PARAM_METRIC: Output.METRIC_SCHEMA,
|
||||
Output.PARAM_DESC: str,
|
||||
Output.PARAM_REMOTE: str,
|
||||
}
|
||||
|
||||
+35
-13
@@ -1,5 +1,4 @@
|
||||
"""Manages progress bars for DVC repo."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from threading import RLock
|
||||
@@ -137,8 +136,8 @@ class Tqdm(tqdm):
|
||||
|
||||
return wrapped
|
||||
|
||||
def as_callback(self, fs, path_info):
|
||||
return FsspecCallback(fs, path_info, self)
|
||||
def as_callback(self):
|
||||
return FsspecCallback(self)
|
||||
|
||||
def close(self):
|
||||
self.postfix["info"] = ""
|
||||
@@ -168,23 +167,46 @@ class Tqdm(tqdm):
|
||||
|
||||
|
||||
class FsspecCallback(fsspec.Callback):
|
||||
def __init__(self, fs, path_info, progress_bar):
|
||||
self.fs = fs
|
||||
self.path_info = path_info
|
||||
def __init__(self, progress_bar):
|
||||
self.progress_bar = progress_bar
|
||||
super().__init__()
|
||||
|
||||
def set_size(self, size):
|
||||
"""``set_size()`` is an API that might be called with ``None`` if the
|
||||
information is not already present on the caller. In that case, we'll
|
||||
retrieve the size from an ``info()`` call."""
|
||||
if size is None:
|
||||
size = self.fs.info(self.path_info)["size"]
|
||||
self.progress_bar.total = size
|
||||
self.progress_bar.refresh()
|
||||
if size is not None:
|
||||
self.progress_bar.total = size
|
||||
self.progress_bar.refresh()
|
||||
super().set_size(size)
|
||||
|
||||
def relative_update(self, inc=1):
|
||||
self.progress_bar.update(inc)
|
||||
super().relative_update(inc)
|
||||
|
||||
def absolute_update(self, value):
|
||||
self.progress_bar.update_to(value)
|
||||
super().absolute_update(value)
|
||||
|
||||
@staticmethod
|
||||
def wrap_fn(cb, fn):
|
||||
def wrapped(*args, **kwargs):
|
||||
res = fn(*args, **kwargs)
|
||||
cb.relative_update()
|
||||
return res
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def tdqm_or_callback_wrapped(
|
||||
fobj, method, total, callback=None, **pbar_kwargs
|
||||
):
|
||||
if callback:
|
||||
from funcy import nullcontext
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
callback.set_size(total)
|
||||
wrapper = CallbackIOWrapper(callback.relative_update, fobj, method)
|
||||
return nullcontext(wrapper)
|
||||
|
||||
return Tqdm.wrapattr(fobj, method, total=total, bytes=True, **pbar_kwargs)
|
||||
|
||||
|
||||
DEFAULT_CALLBACK = fsspec.callbacks.NoOpCallback()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Renderer:
|
||||
def __init__(self, data: Dict):
|
||||
self.data = data
|
||||
|
||||
from dvc.render.utils import get_files
|
||||
|
||||
files = get_files(self.data)
|
||||
|
||||
# we assume comparison of same file between revisions for now
|
||||
assert len(files) == 1
|
||||
self.filename = files.pop()
|
||||
|
||||
def _convert(self, path: "StrPath"):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def DIV(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def generate_html(self, path: "StrPath"):
|
||||
"""this method might edit content of path"""
|
||||
partial = self._convert(path)
|
||||
div_id = f"plot_{self.filename.replace('.', '_').replace('/', '_')}"
|
||||
return self.DIV.format(id=div_id, partial=partial)
|
||||
@@ -1,27 +1,27 @@
|
||||
from typing import Dict, List, Optional
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.types import StrPath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.render import Renderer
|
||||
from dvc.types import StrPath
|
||||
|
||||
PAGE_HTML = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{refresh_tag}
|
||||
<title>DVC Plot</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vega@5.10.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vega-lite@4.8.1"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.5.1"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.18.2"></script>
|
||||
</head>
|
||||
<body>
|
||||
{plot_divs}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
VEGA_DIV_HTML = """<div id = "{id}"></div>
|
||||
<script type = "text/javascript">
|
||||
var spec = {vega_json};
|
||||
vegaEmbed('#{id}', spec);
|
||||
</script>"""
|
||||
|
||||
|
||||
class MissingPlaceholderError(DvcException):
|
||||
def __init__(self, placeholder):
|
||||
@@ -31,14 +31,23 @@ class MissingPlaceholderError(DvcException):
|
||||
class HTML:
|
||||
PLACEHOLDER = "plot_divs"
|
||||
PLACEHOLDER_FORMAT_STR = f"{{{PLACEHOLDER}}}"
|
||||
REFRESH_PLACEHOLDER = "refresh_tag"
|
||||
REFRESH_TAG = '<meta http-equiv="refresh" content="{}">'
|
||||
|
||||
def __init__(self, template: str = None):
|
||||
def __init__(
|
||||
self,
|
||||
template: Optional[str] = None,
|
||||
refresh_seconds: Optional[int] = None,
|
||||
):
|
||||
template = template or PAGE_HTML
|
||||
if self.PLACEHOLDER_FORMAT_STR not in template:
|
||||
raise MissingPlaceholderError(self.PLACEHOLDER_FORMAT_STR)
|
||||
|
||||
self.template = template
|
||||
self.elements: List[str] = []
|
||||
self.refresh_tag = ""
|
||||
if refresh_seconds is not None:
|
||||
self.refresh_tag = self.REFRESH_TAG.format(refresh_seconds)
|
||||
|
||||
def with_metrics(self, metrics: Dict[str, Dict]) -> "HTML":
|
||||
import tabulate
|
||||
@@ -56,41 +65,43 @@ class HTML:
|
||||
self.elements.append(tabulate.tabulate(rows, header, tablefmt="html"))
|
||||
return self
|
||||
|
||||
def with_plots(self, plots: Dict[str, Dict]) -> "HTML":
|
||||
self.elements.extend(
|
||||
[
|
||||
VEGA_DIV_HTML.format(id=f"plot{i}", vega_json=plot)
|
||||
for i, plot in enumerate(plots.values())
|
||||
]
|
||||
)
|
||||
return self
|
||||
|
||||
def with_element(self, html: str) -> "HTML":
|
||||
self.elements.append(html)
|
||||
return self
|
||||
|
||||
def embed(self) -> str:
|
||||
kwargs = {self.PLACEHOLDER: "\n".join(self.elements)}
|
||||
kwargs = {
|
||||
self.PLACEHOLDER: "\n".join(self.elements),
|
||||
self.REFRESH_PLACEHOLDER: self.refresh_tag,
|
||||
}
|
||||
return self.template.format(**kwargs)
|
||||
|
||||
|
||||
def write(
|
||||
path: StrPath,
|
||||
plots: Dict[str, Dict],
|
||||
path: "StrPath",
|
||||
renderers: List["Renderer"],
|
||||
metrics: Optional[Dict[str, Dict]] = None,
|
||||
template_path: Optional[StrPath] = None,
|
||||
template_path: Optional["StrPath"] = None,
|
||||
refresh_seconds: Optional[int] = None,
|
||||
):
|
||||
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
page_html = None
|
||||
if template_path:
|
||||
with open(template_path) as fobj:
|
||||
page_html = fobj.read()
|
||||
|
||||
document = HTML(page_html)
|
||||
document = HTML(page_html, refresh_seconds=refresh_seconds)
|
||||
if metrics:
|
||||
document.with_metrics(metrics)
|
||||
document.with_element("<br>")
|
||||
|
||||
document.with_plots(plots)
|
||||
for renderer in renderers:
|
||||
document.with_element(renderer.generate_html(path))
|
||||
|
||||
with open(path, "w") as fd:
|
||||
index = Path(os.path.join(path, "index.html"))
|
||||
|
||||
with open(index, "w") as fd:
|
||||
fd.write(document.embed())
|
||||
return index
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dvc.render import Renderer
|
||||
from dvc.render.utils import get_files
|
||||
from dvc.utils import relpath
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
|
||||
class ImageRenderer(Renderer):
|
||||
DIV = """
|
||||
<div
|
||||
id="{id}"
|
||||
style="border: 1px solid;">
|
||||
{partial}
|
||||
</div>"""
|
||||
|
||||
def _write_image(
|
||||
self,
|
||||
page_dir_path: "StrPath",
|
||||
revision: str,
|
||||
filename: str,
|
||||
image_data: bytes,
|
||||
):
|
||||
static = os.path.join(page_dir_path, "static")
|
||||
os.makedirs(static, exist_ok=True)
|
||||
|
||||
img_path = os.path.join(
|
||||
static, f"{revision}_{filename.replace(os.sep, '_')}"
|
||||
)
|
||||
with open(img_path, "wb") as fd:
|
||||
fd.write(image_data)
|
||||
return """
|
||||
<div>
|
||||
<p>{title}</p>
|
||||
<img src="{src}">
|
||||
</div>""".format(
|
||||
title=revision, src=(relpath(img_path, page_dir_path))
|
||||
)
|
||||
|
||||
def _convert(self, path: "StrPath"):
|
||||
div_content = []
|
||||
for rev, rev_data in self.data.items():
|
||||
if "data" in rev_data:
|
||||
for file, file_data in rev_data.get("data", {}).items():
|
||||
if "data" in file_data:
|
||||
div_content.append(
|
||||
self._write_image(
|
||||
path, rev, file, file_data["data"]
|
||||
)
|
||||
)
|
||||
if div_content:
|
||||
div_content.insert(0, f"<p>{self.filename}</p>")
|
||||
return "\n".join(div_content)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def matches(data):
|
||||
files = get_files(data)
|
||||
extensions = set(map(lambda f: os.path.splitext(f)[1], files))
|
||||
return extensions.issubset({".jpg", ".jpeg", ".gif", ".png"})
|
||||
@@ -0,0 +1,71 @@
|
||||
import os.path
|
||||
from typing import Dict, List
|
||||
|
||||
import dpath
|
||||
|
||||
|
||||
def get_files(data: Dict) -> List:
|
||||
files = set()
|
||||
for rev in data.keys():
|
||||
for file in data[rev].get("data", {}).keys():
|
||||
files.add(file)
|
||||
sorted_files = sorted(files)
|
||||
return sorted_files
|
||||
|
||||
|
||||
def group_by_filename(plots_data: Dict) -> List[Dict]:
|
||||
files = get_files(plots_data)
|
||||
grouped = []
|
||||
for file in files:
|
||||
grouped.append(dpath.util.search(plots_data, ["*", "*", file]))
|
||||
return grouped
|
||||
|
||||
|
||||
def find_vega(repo, plots_data, target):
|
||||
found = dpath.util.search(plots_data, ["*", "*", target])
|
||||
from dvc.render.vega import VegaRenderer
|
||||
|
||||
if found and VegaRenderer.matches(found):
|
||||
return VegaRenderer(found, repo.plots.templates).get_vega()
|
||||
return ""
|
||||
|
||||
|
||||
def match_renderers(plots_data, templates):
|
||||
from dvc.render.image import ImageRenderer
|
||||
from dvc.render.vega import VegaRenderer
|
||||
|
||||
renderers = []
|
||||
for g in group_by_filename(plots_data):
|
||||
if VegaRenderer.matches(g):
|
||||
renderers.append(VegaRenderer(g, templates))
|
||||
if ImageRenderer.matches(g):
|
||||
renderers.append(ImageRenderer(g))
|
||||
return renderers
|
||||
|
||||
|
||||
def render(
|
||||
repo,
|
||||
plots_data,
|
||||
metrics=None,
|
||||
path=None,
|
||||
html_template_path=None,
|
||||
refresh_seconds=None,
|
||||
):
|
||||
# TODO we could probably remove repo usages (here and in VegaRenderer)
|
||||
renderers = match_renderers(plots_data, repo.plots.templates)
|
||||
if not html_template_path:
|
||||
html_template_path = repo.config.get("plots", {}).get(
|
||||
"html_template", None
|
||||
)
|
||||
if html_template_path and not os.path.isabs(html_template_path):
|
||||
html_template_path = os.path.join(repo.dvc_dir, html_template_path)
|
||||
|
||||
from dvc.render.html import write
|
||||
|
||||
return write(
|
||||
path,
|
||||
renderers,
|
||||
metrics=metrics,
|
||||
template_path=html_template_path,
|
||||
refresh_seconds=refresh_seconds,
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
import os
|
||||
from copy import copy, deepcopy
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Union
|
||||
|
||||
from funcy import first
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.render import Renderer
|
||||
from dvc.render.utils import get_files
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo.plots.template import PlotTemplates
|
||||
from dvc.types import StrPath
|
||||
|
||||
REVISION_FIELD = "rev"
|
||||
INDEX_FIELD = "step"
|
||||
|
||||
|
||||
class PlotMetricTypeError(DvcException):
|
||||
def __init__(self, file):
|
||||
super().__init__(
|
||||
"'{}' - file type error\n"
|
||||
"Only JSON, YAML, CSV and TSV formats are supported.".format(file)
|
||||
)
|
||||
|
||||
|
||||
class PlotDataStructureError(DvcException):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"Plot data extraction failed. Please see "
|
||||
"https://man.dvc.org/plots for supported data formats."
|
||||
)
|
||||
|
||||
|
||||
def _filter_fields(
|
||||
datapoints: List[Dict], filename, revision, fields=None
|
||||
) -> List[Dict]:
|
||||
if not fields:
|
||||
return datapoints
|
||||
assert isinstance(fields, set)
|
||||
|
||||
new_data = []
|
||||
for data_point in datapoints:
|
||||
new_dp = copy(data_point)
|
||||
|
||||
keys = set(data_point.keys())
|
||||
if keys & fields != fields:
|
||||
raise DvcException(
|
||||
"Could not find fields: '{}' for '{}' at '{}'.".format(
|
||||
", ".join(fields), filename, revision
|
||||
)
|
||||
)
|
||||
|
||||
to_del = keys - fields
|
||||
for key in to_del:
|
||||
del new_dp[key]
|
||||
new_data.append(new_dp)
|
||||
return new_data
|
||||
|
||||
|
||||
def _lists(dictionary):
|
||||
for _, value in dictionary.items():
|
||||
if isinstance(value, dict):
|
||||
yield from _lists(value)
|
||||
elif isinstance(value, list):
|
||||
yield value
|
||||
|
||||
|
||||
def _find_data(data: Union[Dict, List], fields=None) -> List[Dict]:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
if not fields:
|
||||
# just look for first list of dicts
|
||||
fields = set()
|
||||
|
||||
for lst in _lists(data):
|
||||
if (
|
||||
all(isinstance(dp, dict) for dp in lst)
|
||||
and set(first(lst).keys()) & fields == fields
|
||||
):
|
||||
return lst
|
||||
raise PlotDataStructureError()
|
||||
|
||||
|
||||
def _append_index(datapoints: List[Dict], append_index=False) -> List[Dict]:
|
||||
if not append_index or INDEX_FIELD in first(datapoints).keys():
|
||||
return datapoints
|
||||
|
||||
for index, data_point in enumerate(datapoints):
|
||||
data_point[INDEX_FIELD] = index
|
||||
return datapoints
|
||||
|
||||
|
||||
def _append_revision(datapoints: List[Dict], revision) -> List[Dict]:
|
||||
for data_point in datapoints:
|
||||
data_point[REVISION_FIELD] = revision
|
||||
return datapoints
|
||||
|
||||
|
||||
class VegaRenderer(Renderer):
|
||||
DIV = """
|
||||
<div id = "{id}">
|
||||
<script type = "text/javascript">
|
||||
var spec = {partial};
|
||||
vegaEmbed('#{id}', spec);
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def __init__(self, data: Dict, templates: "PlotTemplates"):
|
||||
super().__init__(data)
|
||||
self.templates = templates
|
||||
|
||||
def _squash_props(self) -> Dict:
|
||||
resolved: Dict[str, str] = {}
|
||||
for rev_data in self.data.values():
|
||||
for file_data in rev_data.get("data", {}).values():
|
||||
props = file_data.get("props", {})
|
||||
resolved = {**resolved, **props}
|
||||
return resolved
|
||||
|
||||
def _datapoints(self, props: Dict):
|
||||
fields = props.get("fields", set())
|
||||
if fields:
|
||||
fields = {*fields, props.get("x"), props.get("y")} - {None}
|
||||
|
||||
datapoints = []
|
||||
for revision, rev_data in self.data.items():
|
||||
for filename, file_data in rev_data.get("data", {}).items():
|
||||
if "data" in file_data:
|
||||
tmp = deepcopy(file_data.get("data"))
|
||||
tmp = _find_data(tmp, fields=fields - {INDEX_FIELD})
|
||||
tmp = _append_index(
|
||||
tmp, append_index=props.get("append_index", False)
|
||||
)
|
||||
tmp = _filter_fields(
|
||||
tmp,
|
||||
filename=filename,
|
||||
revision=revision,
|
||||
fields=fields,
|
||||
)
|
||||
tmp = _append_revision(tmp, revision=revision)
|
||||
datapoints.extend(tmp)
|
||||
return datapoints
|
||||
|
||||
def get_vega(self) -> Optional[str]:
|
||||
props = self._squash_props()
|
||||
|
||||
template = self.templates.load(props.get("template") or "default")
|
||||
|
||||
if not props.get("x") and template.has_anchor("x"):
|
||||
props["append_index"] = True
|
||||
props["x"] = INDEX_FIELD
|
||||
|
||||
datapoints = self._datapoints(props)
|
||||
|
||||
if datapoints:
|
||||
if not props.get("y") and template.has_anchor("y"):
|
||||
fields = list(first(datapoints))
|
||||
skip = (REVISION_FIELD, props.get("x"))
|
||||
props["y"] = first(
|
||||
f for f in reversed(fields) if f not in skip
|
||||
)
|
||||
return template.render(datapoints, props=props)
|
||||
return None
|
||||
|
||||
def _convert(self, path: "StrPath"):
|
||||
return self.get_vega()
|
||||
|
||||
@staticmethod
|
||||
def matches(data):
|
||||
files = get_files(data)
|
||||
extensions = set(map(lambda f: os.path.splitext(f)[1], files))
|
||||
return extensions.issubset({".yml", ".yaml", ".json", ".csv", ".tsv"})
|
||||
+44
-4
@@ -12,6 +12,7 @@ from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
|
||||
from dvc.exceptions import NotDvcRepoError, OutputNotFoundError
|
||||
from dvc.ignore import DvcIgnoreFilter
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.utils import env2bool
|
||||
from dvc.utils.fs import path_isin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -119,6 +120,34 @@ class Repo:
|
||||
|
||||
return root_dir, dvc_dir, tmp_dir
|
||||
|
||||
def _get_database_dir(self, db_name):
|
||||
# NOTE: by default, store SQLite-based remote indexes and state's
|
||||
# `links` and `md5s` caches in the repository itself to avoid any
|
||||
# possible state corruption in 'shared cache dir' scenario, but allow
|
||||
# user to override this through config when, say, the repository is
|
||||
# located on a mounted volume — see
|
||||
# https://github.com/iterative/dvc/issues/4420
|
||||
base_db_dir = self.config.get(db_name, {}).get("dir", None)
|
||||
if not base_db_dir:
|
||||
return self.tmp_dir
|
||||
|
||||
import hashlib
|
||||
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
root_dir_hash = hashlib.sha224(
|
||||
self.root_dir.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
db_dir = os.path.join(
|
||||
base_db_dir,
|
||||
self.DVC_DIR,
|
||||
f"{os.path.basename(self.root_dir)}-{root_dir_hash[0:7]}",
|
||||
)
|
||||
|
||||
makedirs(db_dir, exist_ok=True)
|
||||
return db_dir
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_dir=None,
|
||||
@@ -134,6 +163,7 @@ class Repo:
|
||||
from dvc.data_cloud import DataCloud
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.lock import LockNoop, make_lock
|
||||
from dvc.machine import MachineManager
|
||||
from dvc.objects.db import ODBManager
|
||||
from dvc.repo.live import Live
|
||||
from dvc.repo.metrics import Metrics
|
||||
@@ -181,10 +211,8 @@ class Repo:
|
||||
friendly=True,
|
||||
)
|
||||
|
||||
# NOTE: storing state and link_state in the repository itself to
|
||||
# avoid any possible state corruption in 'shared cache dir'
|
||||
# scenario.
|
||||
self.state = State(self.root_dir, self.tmp_dir, self.dvcignore)
|
||||
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)
|
||||
@@ -196,6 +224,14 @@ class Repo:
|
||||
self.params = Params(self)
|
||||
self.live = Live(self)
|
||||
|
||||
if self.tmp_dir and (
|
||||
self.config["feature"].get("machine", False)
|
||||
or env2bool("DVC_TEST")
|
||||
):
|
||||
self.machine = MachineManager(self)
|
||||
else:
|
||||
self.machine = None
|
||||
|
||||
self.stage_collection_error_handler: Optional[
|
||||
Callable[[str, Exception], None]
|
||||
] = None
|
||||
@@ -442,6 +478,10 @@ class Repo:
|
||||
|
||||
return RepoFileSystem(self, subrepos=self.subrepos, **self._fs_conf)
|
||||
|
||||
@cached_property
|
||||
def index_db_dir(self):
|
||||
return self._get_database_dir("index")
|
||||
|
||||
@contextmanager
|
||||
def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
|
||||
"""Opens a specified resource as a file descriptor"""
|
||||
|
||||
@@ -49,7 +49,6 @@ def _collect_paths(
|
||||
)
|
||||
else:
|
||||
logger.warning("'%s' was not found at: '%s'.", path_info, rev)
|
||||
continue
|
||||
target_infos.append(path_info)
|
||||
return target_infos
|
||||
|
||||
|
||||
+31
-30
@@ -4,7 +4,6 @@ from collections import defaultdict
|
||||
from typing import Dict, List
|
||||
|
||||
from dvc.exceptions import PathMissingError
|
||||
from dvc.objects.stage import get_file_hash
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.utils import fix_exp_head
|
||||
|
||||
@@ -45,7 +44,7 @@ def diff(self, a_rev="HEAD", b_rev=None, targets=None):
|
||||
repo_fs, targets
|
||||
)
|
||||
|
||||
results[rev] = _paths_checksums(self, repo_fs, targets_path_infos)
|
||||
results[rev] = _paths_checksums(self, targets_path_infos)
|
||||
|
||||
if targets is not None:
|
||||
# check for overlapping missing targets between a_rev and b_rev
|
||||
@@ -98,7 +97,7 @@ def diff(self, a_rev="HEAD", b_rev=None, targets=None):
|
||||
return ret if any(ret.values()) else {}
|
||||
|
||||
|
||||
def _paths_checksums(repo, repo_fs, targets):
|
||||
def _paths_checksums(repo, targets):
|
||||
"""
|
||||
A dictionary of checksums addressed by relpaths collected from
|
||||
the current fs outputs.
|
||||
@@ -110,10 +109,10 @@ def _paths_checksums(repo, repo_fs, targets):
|
||||
file: "data"
|
||||
"""
|
||||
|
||||
return dict(_output_paths(repo, repo_fs, targets))
|
||||
return dict(_output_paths(repo, targets))
|
||||
|
||||
|
||||
def _output_paths(repo, repo_fs, targets):
|
||||
def _output_paths(repo, targets):
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.objects.stage import stage as ostage
|
||||
|
||||
@@ -131,45 +130,47 @@ def _output_paths(repo, repo_fs, targets):
|
||||
else os.path.join(str(output), "")
|
||||
)
|
||||
|
||||
def _to_checksum(output):
|
||||
if on_working_fs:
|
||||
return ostage(
|
||||
repo.odb.local,
|
||||
output.path_info,
|
||||
repo.odb.local.fs,
|
||||
"md5",
|
||||
dry_run=True,
|
||||
)[1].hash_info.value
|
||||
return output.hash_info.value
|
||||
|
||||
for output in repo.index.outs:
|
||||
if _exists(output):
|
||||
yield_output = targets is None or any(
|
||||
output.path_info.isin_or_eq(target) for target in targets
|
||||
)
|
||||
|
||||
if on_working_fs:
|
||||
_, obj = ostage(
|
||||
repo.odb.local,
|
||||
output.path_info,
|
||||
repo.odb.local.fs,
|
||||
"md5",
|
||||
dry_run=True,
|
||||
dvcignore=output.dvcignore,
|
||||
)
|
||||
hash_info = obj.hash_info
|
||||
else:
|
||||
hash_info = output.hash_info
|
||||
obj = output.get_obj()
|
||||
|
||||
if yield_output:
|
||||
yield _to_path(output), _to_checksum(output)
|
||||
yield _to_path(output), hash_info.value
|
||||
|
||||
if not obj:
|
||||
continue
|
||||
|
||||
if output.is_dir_checksum and (
|
||||
yield_output
|
||||
or any(target.isin(output.path_info) for target in targets)
|
||||
):
|
||||
yield from _dir_output_paths(repo_fs, output, targets)
|
||||
yield from _dir_output_paths(output.path_info, obj, targets)
|
||||
|
||||
|
||||
def _dir_output_paths(repo_fs, output, targets=None):
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
try:
|
||||
for fname in repo_fs.walk_files(output.path_info):
|
||||
if targets is None or any(
|
||||
fname.isin_or_eq(target) for target in targets
|
||||
):
|
||||
# pylint: disable=no-member
|
||||
yield str(fname), get_file_hash(fname, repo_fs, "md5").value
|
||||
except NoRemoteError:
|
||||
logger.warning("dir cache entry for '%s' is missing", output)
|
||||
def _dir_output_paths(path_info, obj, targets=None):
|
||||
for key, entry_obj in obj:
|
||||
fname = path_info.joinpath(*key)
|
||||
if targets is None or any(
|
||||
fname.isin_or_eq(target) for target in targets
|
||||
):
|
||||
# pylint: disable=no-member
|
||||
yield str(fname), entry_obj.hash_info.value
|
||||
|
||||
|
||||
def _filter_missing(repo_fs, paths):
|
||||
|
||||
@@ -455,7 +455,7 @@ class Experiments:
|
||||
)
|
||||
logger.info(
|
||||
"\nTo promote an experiment to a Git branch run:\n\n"
|
||||
"\tdvc exp branch <exp>\n"
|
||||
"\tdvc exp branch <exp> <branch>\n"
|
||||
)
|
||||
|
||||
@scm_locked
|
||||
|
||||
@@ -348,6 +348,7 @@ class BaseExecutor(ABC):
|
||||
quiet=True,
|
||||
allow_missing=True,
|
||||
checkpoint_reset=checkpoint_reset,
|
||||
recursive=kwargs.get("recursive", False),
|
||||
)
|
||||
|
||||
checkpoint_func = partial(
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Dict, Iterable
|
||||
|
||||
from funcy import compact, lremove, post_processing
|
||||
from rich.prompt import Prompt
|
||||
|
||||
from dvc.types import OptStr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
from dvc.dvcfile import DVCFile
|
||||
from dvc.stage import Stage
|
||||
|
||||
from dvc.ui import ui
|
||||
|
||||
PROMPTS = {
|
||||
"cmd": "[b]Command[/b] to execute",
|
||||
"code": "Path to a [b]code[/b] file/directory",
|
||||
"data": "Path to a [b]data[/b] file/directory",
|
||||
"models": "Path to a [b]model[/b] file/directory",
|
||||
"params": "Path to a [b]parameters[/b] file",
|
||||
"metrics": "Path to a [b]metrics[/b] file",
|
||||
"plots": "Path to a [b]plots[/b] file/directory",
|
||||
"live": "Path to log [b]dvclive[/b] outputs",
|
||||
}
|
||||
|
||||
|
||||
class RequiredPrompt(Prompt):
|
||||
def process_response(self, value: str):
|
||||
from rich.prompt import InvalidResponse
|
||||
|
||||
ret = super().process_response(value)
|
||||
if not ret:
|
||||
raise InvalidResponse(
|
||||
"[prompt.invalid]Response required. Please try again."
|
||||
)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def get_input(cls, *args, **kwargs) -> str:
|
||||
try:
|
||||
return super().get_input(*args, **kwargs)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
ui.error_write()
|
||||
raise
|
||||
|
||||
def render_default(self, default):
|
||||
from rich.text import Text
|
||||
|
||||
return Text(f"{default!s}", "green")
|
||||
|
||||
|
||||
class SkippablePrompt(RequiredPrompt):
|
||||
skip_value: str = "n"
|
||||
|
||||
def process_response(self, value: str):
|
||||
ret = super().process_response(value)
|
||||
return None if ret == self.skip_value else ret
|
||||
|
||||
def make_prompt(self, default):
|
||||
prompt = self.prompt.copy()
|
||||
prompt.end = ""
|
||||
|
||||
prompt.append(" [")
|
||||
if (
|
||||
default is not ...
|
||||
and self.show_default
|
||||
and isinstance(default, (str, self.response_type))
|
||||
):
|
||||
_default = self.render_default(default)
|
||||
prompt.append(_default)
|
||||
prompt.append(", ")
|
||||
|
||||
prompt.append(f"{self.skip_value} to skip", style="italic")
|
||||
prompt.append("]")
|
||||
prompt.append(self.prompt_suffix)
|
||||
return prompt
|
||||
|
||||
|
||||
@post_processing(dict)
|
||||
def _prompts(keys: Iterable[str], defaults: Dict[str, OptStr]):
|
||||
for key in keys:
|
||||
if key == "cmd":
|
||||
prompt_cls = RequiredPrompt
|
||||
else:
|
||||
prompt_cls = SkippablePrompt
|
||||
kwargs = {"default": defaults[key]} if key in defaults else {}
|
||||
prompt = PROMPTS.get(key)
|
||||
value = prompt_cls.ask(prompt, console=ui.error_console, **kwargs)
|
||||
yield key, value
|
||||
|
||||
|
||||
def init_interactive(
|
||||
defaults: Dict[str, str],
|
||||
provided: Iterable[str],
|
||||
show_heading: bool = False,
|
||||
live: bool = False,
|
||||
) -> Dict[str, str]:
|
||||
primary = lremove(provided, ["cmd", "code", "data", "models", "params"])
|
||||
secondary = lremove(provided, ["live"] if live else ["metrics", "plots"])
|
||||
|
||||
if not (primary or secondary):
|
||||
return {}
|
||||
|
||||
message = (
|
||||
"This command will guide you to set up your first stage in "
|
||||
"[green]dvc.yaml[/green].\n"
|
||||
)
|
||||
if show_heading:
|
||||
ui.error_write(message, styled=True)
|
||||
|
||||
return compact(
|
||||
{
|
||||
**_prompts(primary, defaults),
|
||||
**_prompts(secondary, defaults),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_stage_exists(
|
||||
dvcfile: "DVCFile", name: str, force: bool = False
|
||||
) -> None:
|
||||
if not force and dvcfile.exists() and name in dvcfile.stages:
|
||||
from dvc.stage.exceptions import DuplicateStageName
|
||||
|
||||
hint = "Use '--force' to overwrite."
|
||||
raise DuplicateStageName(
|
||||
f"Stage '{name}' already exists in 'dvc.yaml'. {hint}"
|
||||
)
|
||||
|
||||
|
||||
def init(
|
||||
repo: "Repo",
|
||||
name: str = None,
|
||||
type: str = "default", # pylint: disable=redefined-builtin
|
||||
defaults: Dict[str, str] = None,
|
||||
overrides: Dict[str, str] = None,
|
||||
interactive: bool = False,
|
||||
force: bool = False,
|
||||
) -> "Stage":
|
||||
from dvc.dvcfile import make_dvcfile
|
||||
|
||||
dvcfile = make_dvcfile(repo, "dvc.yaml")
|
||||
name = name or type
|
||||
|
||||
_check_stage_exists(dvcfile, name, force=force)
|
||||
|
||||
defaults = defaults or {}
|
||||
overrides = overrides or {}
|
||||
|
||||
with_live = type == "live"
|
||||
if interactive:
|
||||
defaults = init_interactive(
|
||||
defaults=defaults or {},
|
||||
show_heading=not dvcfile.exists(),
|
||||
live=with_live,
|
||||
provided=overrides.keys(),
|
||||
)
|
||||
else:
|
||||
if with_live:
|
||||
# suppress `metrics`/`params` if live is selected, unless
|
||||
# it is also provided via overrides/cli.
|
||||
# This makes output to be a checkpoint as well.
|
||||
defaults.pop("metrics")
|
||||
defaults.pop("params")
|
||||
else:
|
||||
defaults.pop("live") # suppress live otherwise
|
||||
|
||||
context: Dict[str, str] = {**defaults, **overrides}
|
||||
assert "cmd" in context
|
||||
|
||||
params_kv = []
|
||||
if context.get("params"):
|
||||
from dvc.utils.serialize import LOADERS
|
||||
|
||||
path = context["params"]
|
||||
assert isinstance(path, str)
|
||||
_, ext = os.path.splitext(path)
|
||||
params_kv = [{path: list(LOADERS[ext](path))}]
|
||||
|
||||
checkpoint_out = bool(context.get("live"))
|
||||
models = context.get("models")
|
||||
return repo.stage.add(
|
||||
name=name,
|
||||
cmd=context["cmd"],
|
||||
deps=compact([context.get("code"), context.get("data")]),
|
||||
params=params_kv,
|
||||
metrics_no_cache=compact([context.get("metrics")]),
|
||||
plots_no_cache=compact([context.get("plots")]),
|
||||
live=context.get("live"),
|
||||
force=force,
|
||||
**{"checkpoints" if checkpoint_out else "outs": compact([models])},
|
||||
)
|
||||
@@ -4,7 +4,7 @@ from dvc.exceptions import DvcException, InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.scm_context import scm_context
|
||||
|
||||
from .utils import exp_commits, remote_exp_refs_by_name
|
||||
from .utils import exp_commits, resolve_exp_ref
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +14,11 @@ logger = logging.getLogger(__name__)
|
||||
def pull(
|
||||
repo, git_remote, exp_name, *args, force=False, pull_cache=False, **kwargs
|
||||
):
|
||||
exp_ref = _get_exp_ref(repo, git_remote, exp_name)
|
||||
exp_ref = resolve_exp_ref(repo.scm, exp_name, git_remote)
|
||||
if not exp_ref:
|
||||
raise InvalidArgumentError(
|
||||
f"Experiment '{exp_name}' does not exist in '{git_remote}'"
|
||||
)
|
||||
|
||||
def on_diverged(refname: str, rev: str) -> bool:
|
||||
if repo.scm.get_ref(refname) == rev:
|
||||
@@ -35,33 +39,6 @@ def pull(
|
||||
_pull_cache(repo, exp_ref, **kwargs)
|
||||
|
||||
|
||||
def _get_exp_ref(repo, git_remote, exp_name):
|
||||
if exp_name.startswith("refs/"):
|
||||
return exp_name
|
||||
|
||||
exp_refs = list(remote_exp_refs_by_name(repo.scm, git_remote, exp_name))
|
||||
if not exp_refs:
|
||||
raise InvalidArgumentError(
|
||||
f"Experiment '{exp_name}' does not exist in '{git_remote}'"
|
||||
)
|
||||
if len(exp_refs) > 1:
|
||||
cur_rev = repo.scm.get_rev()
|
||||
for info in exp_refs:
|
||||
if info.baseline_sha == cur_rev:
|
||||
return info
|
||||
msg = [
|
||||
(
|
||||
f"Ambiguous name '{exp_name}' refers to multiple "
|
||||
"experiments in '{git_remote}'. Use full refname to pull one "
|
||||
"of the following:"
|
||||
),
|
||||
"",
|
||||
]
|
||||
msg.extend([f"\t{info}" for info in exp_refs])
|
||||
raise InvalidArgumentError("\n".join(msg))
|
||||
return exp_refs[0]
|
||||
|
||||
|
||||
def _pull_cache(repo, exp_ref, dvc_remote=None, jobs=None, run_cache=False):
|
||||
revs = list(exp_commits(repo.scm, [exp_ref]))
|
||||
logger.debug("dvc fetch experiment '%s'", exp_ref)
|
||||
|
||||
@@ -2,10 +2,9 @@ import logging
|
||||
|
||||
from dvc.exceptions import DvcException, InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.base import ExpRefInfo
|
||||
from dvc.repo.scm_context import scm_context
|
||||
|
||||
from .utils import exp_commits, exp_refs_by_name
|
||||
from .utils import exp_commits, resolve_exp_ref
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,7 +20,11 @@ def push(
|
||||
push_cache=False,
|
||||
**kwargs,
|
||||
):
|
||||
exp_ref = _get_exp_ref(repo, exp_name)
|
||||
exp_ref = resolve_exp_ref(repo.scm, exp_name)
|
||||
if not exp_ref:
|
||||
raise InvalidArgumentError(
|
||||
f"'{exp_name}' is not a valid experiment name"
|
||||
)
|
||||
|
||||
def on_diverged(refname: str, rev: str) -> bool:
|
||||
if repo.scm.get_ref(refname) == rev:
|
||||
@@ -42,33 +45,6 @@ def push(
|
||||
_push_cache(repo, exp_ref, **kwargs)
|
||||
|
||||
|
||||
def _get_exp_ref(repo, exp_name: str) -> ExpRefInfo:
|
||||
if exp_name.startswith("refs/"):
|
||||
return ExpRefInfo.from_ref(exp_name)
|
||||
|
||||
exp_refs = list(exp_refs_by_name(repo.scm, exp_name))
|
||||
if not exp_refs:
|
||||
raise InvalidArgumentError(
|
||||
f"'{exp_name}' is not a valid experiment name"
|
||||
)
|
||||
if len(exp_refs) > 1:
|
||||
cur_rev = repo.scm.get_rev()
|
||||
for info in exp_refs:
|
||||
if info.baseline_sha == cur_rev:
|
||||
return info
|
||||
msg = [
|
||||
(
|
||||
f"Ambiguous name '{exp_name}' refers to multiple "
|
||||
"experiments. Use full refname to push one of the "
|
||||
"following:"
|
||||
),
|
||||
"",
|
||||
]
|
||||
msg.extend([f"\t{info}" for info in exp_refs])
|
||||
raise InvalidArgumentError("\n".join(msg))
|
||||
return exp_refs[0]
|
||||
|
||||
|
||||
def _push_cache(repo, exp_ref, dvc_remote=None, jobs=None, run_cache=False):
|
||||
revs = list(exp_commits(repo.scm, [exp_ref]))
|
||||
logger.debug("dvc push experiment '%s'", exp_ref)
|
||||
|
||||
@@ -6,33 +6,47 @@ from dvc.repo import locked
|
||||
from dvc.repo.scm_context import scm_context
|
||||
from dvc.scm.base import RevError
|
||||
|
||||
from .base import EXPS_NAMESPACE, ExpRefInfo
|
||||
from .utils import exp_refs_by_name, remove_exp_refs
|
||||
from .utils import exp_refs, remove_exp_refs, resolve_exp_ref
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
@scm_context
|
||||
def remove(repo, exp_names=None, queue=False, **kwargs):
|
||||
if not exp_names and not queue:
|
||||
def remove(
|
||||
repo,
|
||||
exp_names=None,
|
||||
queue=False,
|
||||
clear_all=False,
|
||||
remote=None,
|
||||
**kwargs,
|
||||
):
|
||||
if not any([exp_names, queue, clear_all]):
|
||||
return 0
|
||||
|
||||
removed = 0
|
||||
if queue:
|
||||
removed += len(repo.experiments.stash)
|
||||
repo.experiments.stash.clear()
|
||||
removed += _clear_stash(repo)
|
||||
if clear_all:
|
||||
removed += _clear_all(repo)
|
||||
|
||||
if exp_names:
|
||||
remained = _remove_commited_exps(repo, exp_names)
|
||||
remained = _remove_queued_exps(repo, remained)
|
||||
if remained:
|
||||
raise InvalidArgumentError(
|
||||
"'{}' is not a valid experiment".format(";".join(remained))
|
||||
)
|
||||
removed += len(exp_names) - len(remained)
|
||||
removed += _remove_exp_by_names(repo, remote, exp_names)
|
||||
return removed
|
||||
|
||||
|
||||
def _clear_stash(repo):
|
||||
removed = len(repo.experiments.stash)
|
||||
repo.experiments.stash.clear()
|
||||
return removed
|
||||
|
||||
|
||||
def _clear_all(repo):
|
||||
ref_infos = list(exp_refs(repo.scm))
|
||||
remove_exp_refs(repo.scm, ref_infos)
|
||||
return len(ref_infos)
|
||||
|
||||
|
||||
def _get_exp_stash_index(repo, ref_or_rev: str) -> Optional[int]:
|
||||
stash_revs = repo.experiments.stash_revs
|
||||
for _, ref_info in stash_revs.items():
|
||||
@@ -47,46 +61,24 @@ def _get_exp_stash_index(repo, ref_or_rev: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _get_exp_ref(repo, exp_name: str) -> Optional[ExpRefInfo]:
|
||||
cur_rev = repo.scm.get_rev()
|
||||
if exp_name.startswith(EXPS_NAMESPACE):
|
||||
if repo.scm.get_ref(exp_name):
|
||||
return ExpRefInfo.from_ref(exp_name)
|
||||
else:
|
||||
exp_refs = list(exp_refs_by_name(repo.scm, exp_name))
|
||||
if exp_refs:
|
||||
return _get_ref(exp_refs, exp_name, cur_rev)
|
||||
return None
|
||||
|
||||
|
||||
def _get_ref(ref_infos, name, cur_rev) -> Optional[ExpRefInfo]:
|
||||
if len(ref_infos) > 1:
|
||||
for info in ref_infos:
|
||||
if info.baseline_sha == cur_rev:
|
||||
return info
|
||||
msg = [
|
||||
(
|
||||
f"Ambiguous name '{name}' refers to multiple "
|
||||
"experiments. Use full refname to remove one of "
|
||||
"the following:"
|
||||
)
|
||||
]
|
||||
msg.extend([f"\t{info}" for info in ref_infos])
|
||||
raise InvalidArgumentError("\n".join(msg))
|
||||
return ref_infos[0]
|
||||
|
||||
|
||||
def _remove_commited_exps(repo, refs: List[str]) -> List[str]:
|
||||
def _remove_commited_exps(
|
||||
repo, remote: Optional[str], exp_names: List[str]
|
||||
) -> List[str]:
|
||||
remain_list = []
|
||||
remove_list = []
|
||||
for ref in refs:
|
||||
ref_info = _get_exp_ref(repo, ref)
|
||||
for exp_name in exp_names:
|
||||
ref_info = resolve_exp_ref(repo.scm, exp_name, remote)
|
||||
|
||||
if ref_info:
|
||||
remove_list.append(ref_info)
|
||||
else:
|
||||
remain_list.append(ref)
|
||||
remain_list.append(exp_name)
|
||||
if remove_list:
|
||||
remove_exp_refs(repo.scm, remove_list)
|
||||
if not remote:
|
||||
remove_exp_refs(repo.scm, remove_list)
|
||||
else:
|
||||
for ref_info in remove_list:
|
||||
repo.scm.push_refspec(remote, None, str(ref_info))
|
||||
return remain_list
|
||||
|
||||
|
||||
@@ -99,3 +91,14 @@ def _remove_queued_exps(repo, refs_or_revs: List[str]) -> List[str]:
|
||||
else:
|
||||
repo.experiments.stash.drop(stash_index)
|
||||
return remain_list
|
||||
|
||||
|
||||
def _remove_exp_by_names(repo, remote, exp_names: List[str]) -> int:
|
||||
remained = _remove_commited_exps(repo, remote, exp_names)
|
||||
if not remote:
|
||||
remained = _remove_queued_exps(repo, remained)
|
||||
if remained:
|
||||
raise InvalidArgumentError(
|
||||
"'{}' is not a valid experiment".format(";".join(remained))
|
||||
)
|
||||
return len(exp_names) - len(remained)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Generator, Iterable, Optional, Set
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.scm.git import Git
|
||||
|
||||
from .base import (
|
||||
@@ -115,3 +116,44 @@ def fix_exp_head(scm: "Git", ref: Optional[str]) -> Optional[str]:
|
||||
if name == "HEAD" and scm.get_ref(EXEC_BASELINE):
|
||||
return "".join((EXEC_BASELINE, tail))
|
||||
return ref
|
||||
|
||||
|
||||
def resolve_exp_ref(
|
||||
scm, exp_name: str, git_remote: Optional[str] = None
|
||||
) -> Optional[ExpRefInfo]:
|
||||
if exp_name.startswith("refs/"):
|
||||
return ExpRefInfo.from_ref(exp_name)
|
||||
|
||||
if git_remote:
|
||||
exp_ref_list = list(remote_exp_refs_by_name(scm, git_remote, exp_name))
|
||||
else:
|
||||
exp_ref_list = list(exp_refs_by_name(scm, exp_name))
|
||||
|
||||
if not exp_ref_list:
|
||||
return None
|
||||
if len(exp_ref_list) > 1:
|
||||
cur_rev = scm.get_rev()
|
||||
for info in exp_ref_list:
|
||||
if info.baseline_sha == cur_rev:
|
||||
return info
|
||||
if git_remote:
|
||||
msg = [
|
||||
(
|
||||
f"Ambiguous name '{exp_name}' refers to multiple "
|
||||
"experiments. Use full refname to push one of the "
|
||||
"following:"
|
||||
),
|
||||
"",
|
||||
]
|
||||
else:
|
||||
msg = [
|
||||
(
|
||||
f"Ambiguous name '{exp_name}' refers to multiple "
|
||||
f"experiments in '{git_remote}'. Use full refname to pull "
|
||||
"one of the following:"
|
||||
),
|
||||
"",
|
||||
]
|
||||
msg.extend([f"\t{info}" for info in exp_ref_list])
|
||||
raise InvalidArgumentError("\n".join(msg))
|
||||
return exp_ref_list[0]
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@ def fetch(
|
||||
|
||||
for odb, obj_ids in sorted(
|
||||
used.items(),
|
||||
key=lambda item: item[0] and item[0].fs.scheme == Schemes.MEMORY,
|
||||
key=lambda item: item[0] is not None
|
||||
and item[0].fs.scheme == Schemes.MEMORY,
|
||||
):
|
||||
d, f = _fetch(
|
||||
self,
|
||||
|
||||
+7
-3
@@ -2,6 +2,8 @@ import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from dvc.render.utils import render
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -15,10 +17,12 @@ def create_summary(out):
|
||||
|
||||
metrics, plots = out.repo.live.show(str(out.path_info))
|
||||
|
||||
html_path = out.path_info.with_suffix(".html")
|
||||
html_path = out.path_info.fspath + "_dvc_plots"
|
||||
|
||||
out.repo.plots.write_html(html_path, plots, metrics)
|
||||
logger.info(f"\nfile://{os.path.abspath(html_path)}")
|
||||
index_path = render(
|
||||
out.repo, plots, metrics=metrics, path=html_path, refresh_seconds=5
|
||||
)
|
||||
logger.info(f"\nfile://{os.path.abspath(index_path)}")
|
||||
|
||||
|
||||
def summary_path_info(out: "Output") -> Optional["PathInfo"]:
|
||||
|
||||
+11
-138
@@ -8,8 +8,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional
|
||||
from funcy import cached_property, first, project
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.repo.plots.data import PlotMetricTypeError
|
||||
from dvc.types import StrPath
|
||||
from dvc.render.vega import PlotMetricTypeError
|
||||
from dvc.utils import (
|
||||
error_handler,
|
||||
errored_revisions,
|
||||
@@ -113,58 +112,33 @@ class Plots:
|
||||
props = props or {}
|
||||
|
||||
for path, repo_path in plot_files:
|
||||
res[repo_path] = {"props": rev_props}
|
||||
joined_props = {**rev_props, **props}
|
||||
res[repo_path] = {"props": joined_props}
|
||||
res[repo_path].update(
|
||||
parse(
|
||||
fs,
|
||||
path,
|
||||
rev_props=rev_props,
|
||||
props=props,
|
||||
props=joined_props,
|
||||
onerror=onerror,
|
||||
)
|
||||
)
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def render(data, revs=None, props=None, templates=None):
|
||||
"""Renders plots"""
|
||||
props = props or {}
|
||||
|
||||
# Merge data by plot file and apply overriding props
|
||||
plots = _prepare_plots(data, revs, props)
|
||||
|
||||
result = {}
|
||||
for datafile, desc in plots.items():
|
||||
rendered = _render(
|
||||
datafile,
|
||||
desc["data"],
|
||||
desc["props"],
|
||||
templates,
|
||||
)
|
||||
if rendered:
|
||||
result[datafile] = rendered
|
||||
|
||||
return result
|
||||
|
||||
def show(
|
||||
self,
|
||||
targets: List[str] = None,
|
||||
revs=None,
|
||||
props=None,
|
||||
templates=None,
|
||||
recursive=False,
|
||||
onerror=None,
|
||||
):
|
||||
if onerror is None:
|
||||
onerror = onerror_collect
|
||||
data = self.collect(
|
||||
|
||||
return self.collect(
|
||||
targets, revs, recursive, onerror=onerror, props=props
|
||||
)
|
||||
|
||||
if templates is None:
|
||||
templates = self.templates
|
||||
return self.render(data, revs, props, templates)
|
||||
|
||||
def diff(self, *args, **kwargs):
|
||||
from .diff import diff
|
||||
|
||||
@@ -217,26 +191,6 @@ class Plots:
|
||||
|
||||
return PlotTemplates(self.repo.dvc_dir)
|
||||
|
||||
def write_html(
|
||||
self,
|
||||
path: StrPath,
|
||||
plots: Dict[str, Dict],
|
||||
metrics: Optional[Dict[str, Dict]] = None,
|
||||
html_template_path: Optional[StrPath] = None,
|
||||
):
|
||||
if not html_template_path:
|
||||
html_template_path = self.repo.config.get("plots", {}).get(
|
||||
"html_template", None
|
||||
)
|
||||
if html_template_path and not os.path.isabs(html_template_path):
|
||||
html_template_path = os.path.join(
|
||||
self.repo.dvc_dir, html_template_path
|
||||
)
|
||||
|
||||
from dvc.utils.html import write
|
||||
|
||||
write(path, plots, metrics, html_template_path)
|
||||
|
||||
|
||||
def _is_plot(out: "Output") -> bool:
|
||||
return bool(out.plot) or bool(out.live)
|
||||
@@ -264,17 +218,19 @@ def _collect_plots(
|
||||
|
||||
|
||||
@error_handler
|
||||
def parse(fs, path, props=None, rev_props=None, **kwargs):
|
||||
def parse(fs, path, props=None, **kwargs):
|
||||
props = props or {}
|
||||
rev_props = rev_props or {}
|
||||
_, extension = os.path.splitext(path)
|
||||
if extension in (".tsv", ".csv"):
|
||||
header = {**rev_props, **props}.get("header", True)
|
||||
header = props.get("header", True)
|
||||
if extension == ".csv":
|
||||
return _load_sv(path=path, fs=fs, delimiter=",", header=header)
|
||||
return _load_sv(path=path, fs=fs, delimiter="\t", header=header)
|
||||
if extension in LOADERS or extension in (".yml", ".yaml"):
|
||||
return LOADERS[extension](path=path, fs=fs)
|
||||
if extension in (".jpeg", ".jpg", ".gif", ".png"):
|
||||
with fs.open(path, "rb") as fd:
|
||||
return fd.read()
|
||||
raise PlotMetricTypeError(path)
|
||||
|
||||
|
||||
@@ -291,89 +247,6 @@ def _plot_props(out: "Output") -> Dict:
|
||||
return project(out.plot, PLOT_PROPS)
|
||||
|
||||
|
||||
def _prepare_plots(data, revs, props):
|
||||
"""Groups data by plot file.
|
||||
|
||||
Also resolves props conflicts between revs and applies global props.
|
||||
"""
|
||||
# we go in order revs are supplied on props conflict first ones win.
|
||||
revs = iter(data) if revs is None else revs
|
||||
|
||||
plots, props_revs = {}, {}
|
||||
for rev in revs:
|
||||
# Asked for revision without data
|
||||
if rev not in data:
|
||||
continue
|
||||
|
||||
for datafile, desc in data[rev].get("data", {}).items():
|
||||
# We silently skip on an absent data file,
|
||||
# see also try/except/pass in .collect()
|
||||
if "data" not in desc:
|
||||
continue
|
||||
|
||||
# props from command line overwrite plot props from out definition
|
||||
full_props = {**desc["props"], **props}
|
||||
|
||||
if datafile in plots:
|
||||
saved = plots[datafile]
|
||||
if saved["props"] != full_props:
|
||||
logger.warning(
|
||||
f"Inconsistent plot props for '{datafile}' in "
|
||||
f"'{props_revs[datafile]}' and '{rev}'. "
|
||||
f"Going to use ones from '{props_revs[datafile]}'"
|
||||
)
|
||||
|
||||
saved["data"][rev] = desc["data"]
|
||||
else:
|
||||
plots[datafile] = {
|
||||
"props": full_props,
|
||||
"data": {rev: desc["data"]},
|
||||
}
|
||||
# Save rev we got props from
|
||||
props_revs[datafile] = rev
|
||||
|
||||
return plots
|
||||
|
||||
|
||||
def _render(datafile, datas, props, templates):
|
||||
from .data import PlotData, plot_data
|
||||
|
||||
# Copy it to not modify a passed value
|
||||
props = props.copy()
|
||||
|
||||
# Add x and y to fields if set
|
||||
fields = props.get("fields")
|
||||
if fields is not None:
|
||||
fields = {*fields, props.get("x"), props.get("y")} - {None}
|
||||
|
||||
template = templates.load(props.get("template") or "default")
|
||||
|
||||
# If x is not set add index field
|
||||
if not props.get("x") and template.has_anchor("x"):
|
||||
props["append_index"] = True
|
||||
props["x"] = PlotData.INDEX_FIELD
|
||||
|
||||
# Parse all data, preprocess it and collect as a list of dicts
|
||||
data = []
|
||||
for rev, unstructured_data in datas.items():
|
||||
rev_data = plot_data(datafile, rev, unstructured_data).to_datapoints(
|
||||
fields=fields,
|
||||
path=props.get("path"),
|
||||
append_index=props.get("append_index", False),
|
||||
)
|
||||
data.extend(rev_data)
|
||||
|
||||
# If y is not set then use last field not used yet
|
||||
if data:
|
||||
if not props.get("y") and template.has_anchor("y"):
|
||||
fields = list(first(data))
|
||||
skip = (PlotData.REVISION_FIELD, props.get("x"))
|
||||
props["y"] = first(f for f in reversed(fields) if f not in skip)
|
||||
|
||||
return template.render(data, props=props)
|
||||
return None
|
||||
|
||||
|
||||
def _load_sv(path, fs, delimiter=",", header=True):
|
||||
with fs.open(path, "r") as fd:
|
||||
content = fd.read()
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import os
|
||||
from copy import copy
|
||||
|
||||
from funcy import first
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.utils.serialize import ParseError
|
||||
|
||||
|
||||
class PlotMetricTypeError(DvcException):
|
||||
def __init__(self, file):
|
||||
super().__init__(
|
||||
"'{}' - file type error\n"
|
||||
"Only JSON, YAML, CSV and TSV formats are supported.".format(file)
|
||||
)
|
||||
|
||||
|
||||
class PlotDataStructureError(DvcException):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"Plot data extraction failed. Please see "
|
||||
"https://man.dvc.org/plot for supported data formats."
|
||||
)
|
||||
|
||||
|
||||
class PlotParsingError(ParseError):
|
||||
def __init__(self, path, revision):
|
||||
self.path = path
|
||||
self.revision = revision
|
||||
|
||||
super().__init__(path, f"revision: '{revision}'")
|
||||
|
||||
|
||||
def plot_data(filename, revision, content):
|
||||
_, extension = os.path.splitext(filename.lower())
|
||||
if extension in (".json", ".yaml"):
|
||||
return DictData(filename, revision, content)
|
||||
if extension in (".csv", ".tsv"):
|
||||
return ListData(filename, revision, content)
|
||||
raise PlotMetricTypeError(filename)
|
||||
|
||||
|
||||
def _filter_fields(data_points, filename, revision, fields=None, **kwargs):
|
||||
if not fields:
|
||||
return data_points
|
||||
assert isinstance(fields, set)
|
||||
|
||||
new_data = []
|
||||
for data_point in data_points:
|
||||
new_dp = copy(data_point)
|
||||
|
||||
keys = set(data_point.keys())
|
||||
if keys & fields != fields:
|
||||
raise DvcException(
|
||||
"Could not find fields: '{}' for '{}' at '{}'.".format(
|
||||
", ".join(fields), filename, revision
|
||||
)
|
||||
)
|
||||
|
||||
to_del = keys - fields
|
||||
for key in to_del:
|
||||
del new_dp[key]
|
||||
new_data.append(new_dp)
|
||||
return new_data
|
||||
|
||||
|
||||
def _lists(dictionary):
|
||||
for _, value in dictionary.items():
|
||||
if isinstance(value, dict):
|
||||
yield from _lists(value)
|
||||
elif isinstance(value, list):
|
||||
yield value
|
||||
|
||||
|
||||
def _find_data(data, fields=None, **kwargs):
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
if not fields:
|
||||
# just look for first list of dicts
|
||||
fields = set()
|
||||
|
||||
for lst in _lists(data):
|
||||
if (
|
||||
all(isinstance(dp, dict) for dp in lst)
|
||||
and set(first(lst).keys()) & fields == fields
|
||||
):
|
||||
return lst
|
||||
raise PlotDataStructureError()
|
||||
|
||||
|
||||
def _append_index(data_points, append_index=False, **kwargs):
|
||||
if not append_index or PlotData.INDEX_FIELD in first(data_points).keys():
|
||||
return data_points
|
||||
|
||||
for index, data_point in enumerate(data_points):
|
||||
data_point[PlotData.INDEX_FIELD] = index
|
||||
return data_points
|
||||
|
||||
|
||||
def _append_revision(data_points, revision, **kwargs):
|
||||
for data_point in data_points:
|
||||
data_point[PlotData.REVISION_FIELD] = revision
|
||||
return data_points
|
||||
|
||||
|
||||
class PlotData:
|
||||
REVISION_FIELD = "rev"
|
||||
INDEX_FIELD = "step"
|
||||
|
||||
def __init__(self, filename, revision, content, **kwargs):
|
||||
self.filename = filename
|
||||
self.revision = revision
|
||||
self.content = content
|
||||
|
||||
def _processors(self):
|
||||
return [_filter_fields, _append_index, _append_revision]
|
||||
|
||||
def to_datapoints(self, **kwargs):
|
||||
data = self.content
|
||||
|
||||
for data_proc in self._processors():
|
||||
data = data_proc(
|
||||
data, filename=self.filename, revision=self.revision, **kwargs
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
class DictData(PlotData):
|
||||
# For files usually parsed as dicts: eg JSON, Yaml
|
||||
def _processors(self):
|
||||
parent_processors = super()._processors()
|
||||
return [_find_data] + parent_processors
|
||||
|
||||
|
||||
class ListData(PlotData):
|
||||
# For files parsed as list: CSV, TSV
|
||||
pass
|
||||
+20
-1
@@ -1,3 +1,5 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
from voluptuous import Any, Optional, Required, Schema
|
||||
|
||||
from dvc import dependency, output
|
||||
@@ -49,6 +51,7 @@ OUT_PSTAGE_DETAILED_SCHEMA = {
|
||||
Output.PARAM_PERSIST: bool,
|
||||
Output.PARAM_CHECKPOINT: bool,
|
||||
Output.PARAM_DESC: str,
|
||||
Output.PARAM_REMOTE: str,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,11 +96,27 @@ STAGE_DEFINITION = {
|
||||
Optional(StageParams.PARAM_LIVE): Any(str, LIVE_PSTAGE_SCHEMA),
|
||||
}
|
||||
|
||||
|
||||
def either_or(primary, fallback, fallback_includes=None):
|
||||
def validator(data):
|
||||
schema = primary
|
||||
if (
|
||||
isinstance(data, Mapping)
|
||||
and set(fallback_includes or []) & data.keys()
|
||||
):
|
||||
schema = fallback
|
||||
return Schema(schema)(data)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
FOREACH_IN = {
|
||||
Required(FOREACH_KWD): Any(dict, list, str),
|
||||
Required(DO_KWD): STAGE_DEFINITION,
|
||||
}
|
||||
SINGLE_PIPELINE_STAGE_SCHEMA = {str: Any(FOREACH_IN, STAGE_DEFINITION)}
|
||||
SINGLE_PIPELINE_STAGE_SCHEMA = {
|
||||
str: either_or(STAGE_DEFINITION, FOREACH_IN, [FOREACH_KWD, DO_KWD])
|
||||
}
|
||||
MULTI_STAGE_SCHEMA = {
|
||||
STAGES: SINGLE_PIPELINE_STAGE_SCHEMA,
|
||||
VARS_KWD: VARS_SCHEMA,
|
||||
|
||||
@@ -47,6 +47,15 @@ class InvalidRemoteSCMRepo(SCMError):
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
class GitAuthError(SCMError):
|
||||
def __init__(self, url: str):
|
||||
super().__init__(
|
||||
f"HTTP Git authentication is not supported: '{url}'"
|
||||
"\nSee https://dvc.org/doc//user-guide/"
|
||||
"troubleshooting#git-auth"
|
||||
)
|
||||
|
||||
|
||||
class Base:
|
||||
"""Base class for source control management driver implementations."""
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from funcy import cached_property
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.scm.base import InvalidRemoteSCMRepo, SCMError
|
||||
from dvc.scm.base import GitAuthError, InvalidRemoteSCMRepo, SCMError
|
||||
from dvc.utils import relpath
|
||||
|
||||
from ..objects import GitObject
|
||||
@@ -39,7 +39,7 @@ class DulwichObject(GitObject):
|
||||
self.repo = repo
|
||||
self._name = name
|
||||
self._mode = mode
|
||||
self.sha = sha
|
||||
self._sha = sha
|
||||
|
||||
def open(self, mode: str = "r", encoding: str = None):
|
||||
if not encoding:
|
||||
@@ -68,6 +68,14 @@ class DulwichObject(GitObject):
|
||||
self.repo, entry.path.decode(), entry.mode, entry.sha
|
||||
)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self.repo[self.sha].as_raw_string())
|
||||
|
||||
@property
|
||||
def sha(self) -> str:
|
||||
return self._sha
|
||||
|
||||
|
||||
class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
"""Dulwich Git backend."""
|
||||
@@ -347,7 +355,7 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
yield os.fsdecode(key)
|
||||
|
||||
def iter_remote_refs(self, url: str, base: Optional[str] = None):
|
||||
from dulwich.client import get_transport_and_path
|
||||
from dulwich.client import HTTPUnauthorized, get_transport_and_path
|
||||
from dulwich.errors import NotGitRepository
|
||||
from dulwich.porcelain import get_remote_repo
|
||||
|
||||
@@ -368,6 +376,8 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
yield from (os.fsdecode(ref) for ref in client.get_refs(path))
|
||||
except NotGitRepository as exc:
|
||||
raise InvalidRemoteSCMRepo(url) from exc
|
||||
except HTTPUnauthorized:
|
||||
raise GitAuthError(url)
|
||||
|
||||
def get_refs_containing(self, rev: str, pattern: Optional[str] = None):
|
||||
raise NotImplementedError
|
||||
@@ -380,7 +390,7 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
force: bool = False,
|
||||
on_diverged: Optional[Callable[[str, str], bool]] = None,
|
||||
):
|
||||
from dulwich.client import get_transport_and_path
|
||||
from dulwich.client import HTTPUnauthorized, get_transport_and_path
|
||||
from dulwich.errors import NotGitRepository, SendPackError
|
||||
from dulwich.porcelain import (
|
||||
DivergedBranches,
|
||||
@@ -399,9 +409,11 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
) from exc
|
||||
|
||||
def update_refs(refs):
|
||||
from dulwich.objects import ZERO_SHA
|
||||
|
||||
new_refs = {}
|
||||
for ref, value in zip(dest_refs, values):
|
||||
if ref in refs:
|
||||
if ref in refs and value != ZERO_SHA:
|
||||
local_sha = self.repo.refs[ref]
|
||||
remote_sha = refs[ref]
|
||||
try:
|
||||
@@ -437,6 +449,8 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
)
|
||||
except (NotGitRepository, SendPackError) as exc:
|
||||
raise SCMError("Git failed to push '{src}' to '{url}'") from exc
|
||||
except HTTPUnauthorized:
|
||||
raise GitAuthError(url)
|
||||
|
||||
def _push_dest_refs(
|
||||
self, src: Optional[str], dest: str
|
||||
|
||||
@@ -92,6 +92,14 @@ class GitPythonObject(GitObject):
|
||||
for obj in self.obj:
|
||||
yield GitPythonObject(obj)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return self.obj.size
|
||||
|
||||
@property
|
||||
def sha(self) -> str:
|
||||
return self.obj.hexsha
|
||||
|
||||
|
||||
class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method
|
||||
"""git-python Git backend."""
|
||||
|
||||
@@ -58,6 +58,14 @@ class Pygit2Object(GitObject):
|
||||
return stat.S_IFDIR
|
||||
return self.obj.filemode
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self.obj.read_raw())
|
||||
|
||||
@property
|
||||
def sha(self) -> str:
|
||||
return self.obj.hex
|
||||
|
||||
def scandir(self) -> Iterable["Pygit2Object"]:
|
||||
for entry in self.obj: # noqa: B301
|
||||
yield Pygit2Object(entry)
|
||||
|
||||
+17
-3
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
import stat
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
@@ -44,6 +43,16 @@ class GitObject(ABC):
|
||||
def issubmodule(self) -> bool:
|
||||
return S_ISGITLINK(self.mode)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def size(self) -> int:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def sha(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class GitTrie:
|
||||
def __init__(self, tree: GitObject, rev: str):
|
||||
@@ -116,9 +125,14 @@ class GitTrie:
|
||||
if not topdown:
|
||||
yield top, dirs, nondirs
|
||||
|
||||
def stat(self, key: tuple) -> os.stat_result:
|
||||
def info(self, key: tuple) -> dict:
|
||||
obj = self.trie[key]
|
||||
return os.stat_result((obj.mode, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||||
return {
|
||||
"size": obj.size,
|
||||
"type": "directory" if stat.S_ISDIR(obj.mode) else "file",
|
||||
"sha": obj.sha,
|
||||
"mode": obj.mode,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -273,7 +273,7 @@ class Stage(params.StageParams):
|
||||
from dvc.output import Output
|
||||
from dvc.schema import LIVE_PROPS
|
||||
|
||||
env[DVCLIVE_PATH] = str(out.path_info)
|
||||
env[DVCLIVE_PATH] = relpath(out.path_info, self.wdir)
|
||||
if isinstance(out.live, dict):
|
||||
config = project(out.live, LIVE_PROPS)
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@ class StageCmdFailedError(DvcException):
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
class StageFileFormatError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class StageFileDoesNotExistError(DvcException):
|
||||
DVC_IGNORED = "is dvc-ignored"
|
||||
DOES_NOT_EXIST = "does not exist"
|
||||
|
||||
@@ -28,6 +28,7 @@ PARAM_PLOT = Output.PARAM_PLOT
|
||||
PARAM_PERSIST = Output.PARAM_PERSIST
|
||||
PARAM_CHECKPOINT = Output.PARAM_CHECKPOINT
|
||||
PARAM_DESC = Output.PARAM_DESC
|
||||
PARAM_REMOTE = Output.PARAM_REMOTE
|
||||
|
||||
DEFAULT_PARAMS_FILE = ParamsDependency.DEFAULT_PARAMS_FILE
|
||||
|
||||
@@ -52,6 +53,8 @@ def _get_flags(out):
|
||||
yield from out.plot.items()
|
||||
if out.live and isinstance(out.live, dict):
|
||||
yield from out.live.items()
|
||||
if out.remote:
|
||||
yield PARAM_REMOTE, out.remote
|
||||
|
||||
|
||||
def _serialize_out(out):
|
||||
|
||||
+10
-22
@@ -53,7 +53,6 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
|
||||
self.tmp_dir = tmp_dir
|
||||
self.root_dir = root_dir
|
||||
self.dvcignore = dvcignore
|
||||
self.fs = LocalFileSystem()
|
||||
|
||||
if not tmp_dir:
|
||||
return
|
||||
@@ -80,12 +79,7 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
|
||||
if not isinstance(fs, LocalFileSystem):
|
||||
return
|
||||
|
||||
assert isinstance(path_info, str) or path_info.scheme == "local"
|
||||
assert hash_info
|
||||
assert isinstance(hash_info, HashInfo)
|
||||
assert os.path.exists(path_info)
|
||||
|
||||
mtime, size = get_mtime_and_size(path_info, self.fs, self.dvcignore)
|
||||
mtime, size = get_mtime_and_size(path_info, fs, self.dvcignore)
|
||||
inode = get_inode(path_info)
|
||||
|
||||
logger.debug(
|
||||
@@ -112,17 +106,12 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
|
||||
if not isinstance(fs, LocalFileSystem):
|
||||
return None
|
||||
|
||||
assert isinstance(path_info, str) or path_info.scheme == "local"
|
||||
path = os.fspath(path_info)
|
||||
|
||||
# NOTE: use os.path.exists instead of LocalFileSystem.exists
|
||||
# because it uses lexists() and will return True for broken
|
||||
# symlinks that we cannot stat() in get_mtime_and_size
|
||||
if not os.path.exists(path):
|
||||
try:
|
||||
mtime, size = get_mtime_and_size(path_info, fs, self.dvcignore)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
mtime, size = get_mtime_and_size(path, self.fs, self.dvcignore)
|
||||
inode = get_inode(path)
|
||||
inode = get_inode(path_info)
|
||||
|
||||
value = self.md5s.get(inode)
|
||||
|
||||
@@ -141,12 +130,11 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
|
||||
if not isinstance(fs, LocalFileSystem):
|
||||
return
|
||||
|
||||
assert isinstance(path_info, str) or path_info.scheme == "local"
|
||||
|
||||
if not self.fs.exists(path_info):
|
||||
try:
|
||||
mtime, _ = get_mtime_and_size(path_info, fs, self.dvcignore)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
mtime, _ = get_mtime_and_size(path_info, self.fs, self.dvcignore)
|
||||
inode = get_inode(path_info)
|
||||
relative_path = relpath(path_info, self.root_dir)
|
||||
|
||||
@@ -168,11 +156,11 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
|
||||
for relative_path in ref:
|
||||
path = os.path.join(self.root_dir, relative_path)
|
||||
|
||||
if path in used or not self.fs.exists(path):
|
||||
if path in used or not fs.exists(path):
|
||||
continue
|
||||
|
||||
inode = get_inode(path)
|
||||
mtime, _ = get_mtime_and_size(path, self.fs, self.dvcignore)
|
||||
mtime, _ = get_mtime_and_size(path, fs, self.dvcignore)
|
||||
|
||||
if ref[relative_path] == (inode, mtime):
|
||||
logger.debug("Removing '%s' as unused link.", path)
|
||||
|
||||
+3
-105
@@ -2,30 +2,13 @@ import errno
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if (
|
||||
sys.platform == "win32"
|
||||
and sys.version_info < (3, 8)
|
||||
and sys.getwindowsversion() >= (6, 2)
|
||||
):
|
||||
try:
|
||||
import speedcopy
|
||||
|
||||
speedcopy.patch_copyfile()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class System:
|
||||
@staticmethod
|
||||
def is_unix():
|
||||
return os.name != "nt"
|
||||
|
||||
@staticmethod
|
||||
def hardlink(source, link_name):
|
||||
try:
|
||||
@@ -117,99 +100,14 @@ class System:
|
||||
# so we need to chmod it to look like a normal copy.
|
||||
os.chmod(link_name, 0o666 & ~umask)
|
||||
|
||||
@staticmethod
|
||||
def _getdirinfo(path):
|
||||
from collections import namedtuple
|
||||
|
||||
from win32file import ( # pylint: disable=import-error
|
||||
FILE_FLAG_BACKUP_SEMANTICS,
|
||||
FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
FILE_SHARE_READ,
|
||||
OPEN_EXISTING,
|
||||
CreateFileW,
|
||||
GetFileInformationByHandle,
|
||||
)
|
||||
|
||||
# NOTE: use FILE_FLAG_OPEN_REPARSE_POINT to open symlink itself and not
|
||||
# the target See https://docs.microsoft.com/en-us/windows/desktop/api/
|
||||
# fileapi/nf-fileapi-createfilew#symbolic-link-behavior
|
||||
flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT
|
||||
|
||||
hfile = CreateFileW(
|
||||
path, 0, FILE_SHARE_READ, None, OPEN_EXISTING, flags, None
|
||||
)
|
||||
|
||||
# See BY_HANDLE_FILE_INFORMATION structure from fileapi.h
|
||||
Info = namedtuple(
|
||||
"BY_HANDLE_FILE_INFORMATION",
|
||||
[
|
||||
"dwFileAttributes",
|
||||
"ftCreationTime",
|
||||
"ftLastAccessTime",
|
||||
"ftLastWriteTime",
|
||||
"dwVolumeSerialNumber",
|
||||
"nFileSizeHigh",
|
||||
"nFileSizeLow",
|
||||
"nNumberOfLinks",
|
||||
"nFileIndexHigh",
|
||||
"nFileIndexLow",
|
||||
],
|
||||
)
|
||||
|
||||
return Info(*GetFileInformationByHandle(hfile))
|
||||
|
||||
@staticmethod
|
||||
def inode(path):
|
||||
path = os.fspath(path)
|
||||
|
||||
if System.is_unix():
|
||||
import ctypes
|
||||
|
||||
inode = os.lstat(path).st_ino
|
||||
# NOTE: See https://bugs.python.org/issue29619 and
|
||||
# https://stackoverflow.com/questions/34643289/
|
||||
# pythons-os-stat-is-returning-wrong-inode-value
|
||||
inode = ctypes.c_ulong(inode).value
|
||||
else:
|
||||
# getdirinfo from ntfsutils works on both files and dirs
|
||||
info = System._getdirinfo(path)
|
||||
inode = abs(
|
||||
hash(
|
||||
(
|
||||
info.dwVolumeSerialNumber,
|
||||
info.nFileIndexHigh,
|
||||
info.nFileIndexLow,
|
||||
)
|
||||
)
|
||||
)
|
||||
assert inode >= 0
|
||||
assert inode < 2 ** 64
|
||||
return inode
|
||||
return os.lstat(path).st_ino
|
||||
|
||||
@staticmethod
|
||||
def is_symlink(path):
|
||||
path = os.fspath(path)
|
||||
|
||||
if System.is_unix():
|
||||
return os.path.islink(path)
|
||||
|
||||
# https://docs.microsoft.com/en-us/windows/desktop/fileio/
|
||||
# file-attribute-constants
|
||||
from winnt import ( # pylint: disable=import-error
|
||||
FILE_ATTRIBUTE_REPARSE_POINT,
|
||||
)
|
||||
|
||||
if os.path.lexists(path):
|
||||
info = System._getdirinfo(path)
|
||||
return info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT
|
||||
return False
|
||||
return os.path.islink(path)
|
||||
|
||||
@staticmethod
|
||||
def is_hardlink(path):
|
||||
path = os.fspath(path)
|
||||
|
||||
if System.is_unix():
|
||||
return os.stat(path).st_nlink > 1
|
||||
|
||||
info = System._getdirinfo(path)
|
||||
return info.nNumberOfLinks > 1
|
||||
return os.stat(path).st_nlink > 1
|
||||
|
||||
+57
-6
@@ -1,12 +1,15 @@
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Optional,
|
||||
Sequence,
|
||||
TextIO,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
@@ -14,6 +17,7 @@ from funcy import cached_property
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.status import Status
|
||||
from rich.text import Text as RichText
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.ui.table import Headers, Styles, TableData
|
||||
@@ -40,8 +44,13 @@ class Console:
|
||||
def __init__(
|
||||
self, formatter: Formatter = None, enable: bool = False
|
||||
) -> None:
|
||||
from contextvars import ContextVar
|
||||
|
||||
self.formatter: Formatter = formatter or Formatter()
|
||||
self._enabled: bool = enable
|
||||
self._paginate: ContextVar[bool] = ContextVar(
|
||||
"_paginate", default=False
|
||||
)
|
||||
|
||||
def enable(self) -> None:
|
||||
self._enabled = True
|
||||
@@ -74,6 +83,23 @@ class Console:
|
||||
styled=styled,
|
||||
)
|
||||
|
||||
def write_json(
|
||||
self, data: Any, indent: int = 2, default: Callable = None
|
||||
) -> None:
|
||||
import json
|
||||
|
||||
if self.isatty():
|
||||
from rich.highlighter import JSONHighlighter
|
||||
|
||||
j = json.dumps(data, indent=indent, default=default)
|
||||
highlighter = JSONHighlighter()
|
||||
text = highlighter(j)
|
||||
text.no_wrap = True
|
||||
text.overflow = None
|
||||
return self.write(text, styled=True)
|
||||
|
||||
return self.write(json.dumps(data))
|
||||
|
||||
def write(
|
||||
self,
|
||||
*objects: Any,
|
||||
@@ -85,6 +111,8 @@ class Console:
|
||||
styled: bool = False,
|
||||
file: TextIO = None,
|
||||
) -> None:
|
||||
import sys
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
sep = " " if sep is None else sep
|
||||
@@ -94,21 +122,39 @@ class Console:
|
||||
|
||||
file = file or (sys.stderr if stderr else sys.stdout)
|
||||
with Tqdm.external_write_mode(file=file):
|
||||
if styled:
|
||||
# if we are inside pager context, send the output to rich's buffer
|
||||
if styled or self._paginate.get():
|
||||
console = self.error_console if stderr else self.rich_console
|
||||
return console.print(*objects, sep=sep, end=end)
|
||||
if styled:
|
||||
return console.print(*objects, sep=sep, end=end)
|
||||
return console.out(*objects, sep=sep, end=end, highlight=False)
|
||||
|
||||
values = (
|
||||
self.formatter.format(obj, style=style) for obj in objects
|
||||
)
|
||||
values = (self.formatter.format(obj, style) for obj in objects)
|
||||
return print(*values, sep=sep, end=end, file=file)
|
||||
|
||||
@property
|
||||
def rich_text(self) -> "Type[RichText]":
|
||||
from rich.text import Text
|
||||
|
||||
return Text
|
||||
|
||||
@staticmethod
|
||||
def progress(*args, **kwargs) -> "Tqdm":
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
return Tqdm(*args, **kwargs)
|
||||
|
||||
@contextmanager
|
||||
def pager(self, styles: bool = True) -> Iterator[None]:
|
||||
from .pager import DvcPager
|
||||
|
||||
tok = self._paginate.set(True)
|
||||
try:
|
||||
with self.rich_console.pager(pager=DvcPager(), styles=styles):
|
||||
yield
|
||||
finally:
|
||||
self._paginate.reset(tok)
|
||||
|
||||
def prompt(
|
||||
self, text: str, choices: Iterable[str] = None, password: bool = False
|
||||
) -> Optional[str]:
|
||||
@@ -195,6 +241,11 @@ class Console:
|
||||
def status(self, status: str, **kwargs: Any) -> "Status":
|
||||
return self.error_console.status(status, **kwargs)
|
||||
|
||||
def isatty(self) -> bool:
|
||||
import sys
|
||||
|
||||
return sys.stdout.isatty()
|
||||
|
||||
|
||||
ui = Console()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user