Compare commits

..
42 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 09946847d2 dvc: bump to 2.4.1 2021-06-23 16:08:10 +03:00
Saugat PachhaiandRuslan Kuprieiev 6e5e62857b Bump fsspec version (#6214)
* Bump fsspec version

* local: use fsspec 2021.06.0 with a temp hack

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-06-23 16:07:32 +03:00
Saugat Pachhai b7c6df0ece Create CODEOWNERS (#6213) 2021-06-23 11:56:37 +05:45
Ruslan Kuprieiev 6779d3d431 dvc: bump to 2.4.0 2021-06-22 20:14:46 +03:00
pre-commit-ci[bot]andRuslan Kuprieiev 5844a0cd5b [pre-commit.ci] pre-commit autoupdate (#6167)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/ambv/black: 21.5b2 → 21.6b0](https://github.com/ambv/black/compare/21.5b2...21.6b0)
- [github.com/timothycrosley/isort: 5.8.0 → 5.9.1](https://github.com/timothycrosley/isort/compare/5.8.0...5.9.1)
- [github.com/pre-commit/mirrors-mypy: v0.812 → v0.902](https://github.com/pre-commit/mirrors-mypy/compare/v0.812...v0.902)

* test_requirements: add types packages

* pre-commit: add additional dependencies for mypy

* pre-commit: fix formatting

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-06-22 13:59:11 +03:00
Saugat Pachhai ce3a3ab100 pass size to upload_fobj (#6187)
This allows us to select between chunked and non-chunked uploading
in Webdav, and hopefully in the http itself.
2021-06-22 13:16:15 +03:00
yehiyam d1a219df0a repo: preserve meta field on move (#6201)
fixes #6200
2021-06-22 13:12:20 +03:00
Saugat Pachhai 476af100de ui: migrating rest to the ui.write (#6176)
* complete migration to ui.write

* fix small bugs
2021-06-21 22:14:59 +03:00
Saugat Pachhai f2998630a3 env: track DVC_IGNORE_ISATTY as a env that we support (#6202) 2021-06-21 05:35:39 +00:00
Laurens Duijvesteijn 9a7b6d2a1a serialize: Wrap UnicodeDecodeErrors (#6193)
Also expose `encoding` and `path` as attributes, so programmatic
consumers can access it easily.
2021-06-19 11:35:50 +05:45
Peter Rowlands (변기호) 1d3524f144 odb: treat external repo dependencies as objects (#6109)
* objects: add ExternalRepoFile object type

* RepoDependency: implement get_obj (use ExternalRepoFile)

* drop distinction between used external and used objects

* update tests

* remove unneeded fetch from ExternalRepoFile

* wrap erepo errors as an object error

* update cloud/remote functions for new used_objs behavior

* fix update() rev/locked behavior

* update import tests

* remove ExternalRepoFile

* move get_obj and get_used_objs implementations back into repo dependency

* return objects plus associated ODB (remote) in get_used_objs

* odb: add tmp_dir field and support creating Remote from an odb

* return dict mapping odb -> objects in get_used_objs

* use dummy git odb for git imports

* fetch: support pulling from specific odb

* update cloud funcs for get_used_objs usage

* update tests for used_objs behavior

* use fetch in dep.download

* catch circular imports from local fs repos

* add tests for chained and circular imports

* move tmp_dir into odb config

* update return docstring for repo.used_objs
2021-06-17 14:41:33 +03:00
Batuhan Taskaya 1a5fa228ab azure: add allow_anonymous_login option to the schema (#6190) 2021-06-17 14:24:07 +03:00
Saugat Pachhai adeb9ef63e fix webdav tests and then add some (#6189) 2021-06-17 13:10:53 +03:00
dependabot[bot] 3e93bc286f build(deps): bump pytest-xdist from 2.2.1 to 2.3.0 (#6188)
Bumps [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) from 2.2.1 to 2.3.0.
- [Release notes](https://github.com/pytest-dev/pytest-xdist/releases)
- [Changelog](https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-xdist/compare/v2.2.1...v2.3.0)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-17 09:37:36 +05:45
Batuhan Taskaya ea89f4a102 add, import-url: preserve size for to-remote/to-cache (#6162) 2021-06-16 18:42:51 +03:00
dependabot[bot] 78299ad345 build(deps): bump pytest-docker from 0.10.1 to 0.10.3 (#6172)
Bumps [pytest-docker](https://github.com/avast/pytest-docker) from 0.10.1 to 0.10.3.
- [Release notes](https://github.com/avast/pytest-docker/releases)
- [Changelog](https://github.com/avast/pytest-docker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/avast/pytest-docker/compare/v0.10.1...v0.10.3)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-15 14:00:38 +05:45
Batuhan Taskaya b9df4451b1 s3, gs, azure: re-enable prefix-based search optimization (#6151) 2021-06-13 16:55:36 +03:00
Saugat Pachhai df67dc2db8 webdav: bring back support for user in url (#6155)
Also added some optimizations, and merged Webdavs into the same file.
2021-06-11 19:52:42 +05:45
dependabot[bot] 37fb73606c build(deps): bump mypy from 0.901 to 0.902 (#6153)
Bumps [mypy](https://github.com/python/mypy) from 0.901 to 0.902.
- [Release notes](https://github.com/python/mypy/releases)
- [Commits](https://github.com/python/mypy/compare/v0.901...v0.902)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-11 15:17:38 +05:45
Batuhan Taskaya 0deddf81cf setup: bump fsspec/s3fs/gcsfs to 2021.06.0 (#6137)
* setup: bump fsspec/s3fs/gcsfs to 2021.06.0

* bump fsspec to upstream
2021-06-10 15:17:43 +03:00
Ruslan Kuprieiev 429d54e146 stage: monitor: don't forget to unprotect outs (#6134)
Fixes #6083
2021-06-10 10:53:51 +02:00
dependabot[bot] 32859763be build(deps): bump mypy from 0.812 to 0.901 (#6140)
Bumps [mypy](https://github.com/python/mypy) from 0.812 to 0.901.
- [Release notes](https://github.com/python/mypy/releases)
- [Commits](https://github.com/python/mypy/compare/v0.812...v0.901)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-10 10:56:39 +05:45
Saugat Pachhai 5012494213 http/webdav: support specifying certs in ssl (#6142) 2021-06-10 02:24:56 +03:00
dependabot[bot] 57c728269d build(deps): bump codecov/codecov-action from 1.5.0 to 1.5.2 (#6139)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 1.5.0 to 1.5.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v1.5.0...v1.5.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-09 23:30:31 +05:45
pre-commit-ci[bot] 3ca18cb400 [pre-commit.ci] pre-commit autoupdate (#6135)
updates:
- [github.com/ambv/black: 21.5b1 → 21.5b2](https://github.com/ambv/black/compare/21.5b1...21.5b2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-06-08 12:29:17 +05:45
Dmitry PetrovandIvan Shcheklein 1335c7f5e4 Add Studio to the related technologies list (#6131)
* Add Studio to the related technologies list

* Add CML to DVC Studio description

* fix k8s names a bit

Co-authored-by: Ivan Shcheklein <[email protected]>
2021-06-07 23:48:35 +03:00
Ruslan Kuprieiev 8b9b2f6907 ls: specify rev as kwarg (#6121)
Fixes #5865
2021-06-07 18:36:35 +03:00
tomrutterandTom Rutter 2f8cf8d7d3 add: prevent crash while getting relpath (#5837)
* add: prevent crash while getting relpath

Since python 3.8 os.path.realpath has resolved windows network shares
to UNC paths. This breaks the logic for relpath as the UNC and mapped
drive paths don't have an obvious common prefix. As it is not easy to go
back to the mapped drive path from UNC path, this is resolved by always
using the UNC paths for calculating relative paths in windows.

Fixes #5832

* relpath: Update changes following review

Two changes:
 - use pytest.mark.skipif rather than if statement inside test to make
   it clearer that test is windows specific
 - Use tail rather than upper_path to denote rest of path after drive
    name taken off (to match python documentation)

Pull request: #5837

* tests, linter: update to add test case and apply linter though pre-commit

Co-authored-by: Tom Rutter <[email protected]>
2021-06-07 11:33:11 +02:00
dependabot[bot] bf5c28b71a build(deps): bump rtCamp/action-slack-notify from 2.1.3 to 2.2.0 (#6128)
Bumps [rtCamp/action-slack-notify](https://github.com/rtCamp/action-slack-notify) from 2.1.3 to 2.2.0.
- [Release notes](https://github.com/rtCamp/action-slack-notify/releases)
- [Commits](https://github.com/rtCamp/action-slack-notify/compare/v2.1.3...v2.2.0)

---
updated-dependencies:
- dependency-name: rtCamp/action-slack-notify
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-07 14:04:32 +05:45
Saugat Pachhai ed2a0b4282 webdav4: use webdav4 (#6099) 2021-06-04 16:42:26 +03:00
eb46c03c6c output/remote: get rid of NamedCache (#6008)
* objects: make HashInfo & HashFile hashable

* stage/output: collect objects (HashFiles) instead of NamedCache

* remote/data_cloud: use objs instead of NamedCache

* repo: use objects instead of NamedCache

* rerun black after rebase

* remove NamedCache usage from run-cache

* drop NamedCache

* update HashInfo.__hash__

* update unit tests

* revert remote_name change

* start cleaning up test failures

* fix tree obj expansion

* update missing cache logging

* fix external dict handling

* update naive odb.gc

* update state save behavior for pull

* update test_data_cloud

* update test_remote

* fix gc with multiple repos

* objects.tree: support filtering w/preserved hash_info

* fix stage cache restore

* drop fs.path_info from HashFile.__hash__

* objects: add optional name attribute

* revert missing cache logging name/suffix changes

* revert name changes in remote tests

* review fixes

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

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

* Update dvc/remote/base.py

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

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

Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-06-02 22:19:58 +03:00
Ruslan Kuprieiev 5fb181fcf8 odb: don't use fs.path_info (#6059)
* odb: don't use fs.path_info

* fs: get rid of fs.path_info
2021-06-02 20:43:51 +03:00
Batuhan Taskaya 0e3db0d323 ci: move to ubuntu-18 from ubuntu-16 (#6101) 2021-06-02 17:31:28 +03:00
Ruslan Kuprieiev 5e4de841d1 dvc: bump to 2.3.0 2021-06-02 15:08:23 +03:00
Batuhan Taskaya 9e822edafc fs: temporarily disable prefix-based search for fsspec-implementations (#6096)
* fs: temporarily disable prefix-based search for fsspec-implementations

* tests: add the other hash
2021-06-02 15:06:48 +03:00
dependabot[bot] 9d8b0b80a0 build(deps): bump pytest-cov from 2.12.0 to 2.12.1 (#6095)
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.12.0 to 2.12.1.
- [Release notes](https://github.com/pytest-dev/pytest-cov/releases)
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.12.0...v2.12.1)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-02 14:42:32 +03:00
rgvanwesepandRobert Van Wesep 59cfc2c4a4 config, remote: Made S3 CA bundle customizable (#6018)
* config, remote: Made S3 CA bundle customizable

botocore allows a path to a custom CA bundle either by passing a path
to the CA bundle file into the verify argument of
boto3.session.Session.client or passing None (the default) which will
fall back to the AWS config. Previously, the DVC config only accepted a
boolean into the ssl_verify option in the remote S3 config. This changes
the DVC config to accept both string and None in addition to boolean and
defaults to None. I also changed the default for ssl_verfiy to None in
BaseS3FileSystem. Thus, if ssl_verify is not provided, botocore will
fall back to the AWS config.

Testing

Unit tests to cover the changes to the config schema and addition
ssl_verify types that will be passed into S3FileSystem. Also, ran

dvc push -r object-store data/cifar-10-python.tar.gz

in my work environment that has a private S3 endpoint that requires a
custom CA bundle, both with and without ssl_verify specified in the
config. This was successful, showing that communication could be
established. And I ran

dvc remote modify object-store ssl_verify "$HOME/.aws/cabundle.pem"

and confirmed that the custom CA bundle path was added to the config.

Fixes #6012

* Removed default None on ssl_verify

Responding to PR comment, removed the Optional, default None on
ssl_verify since the config keys are optional by default. Rather
than a missing ssl_verify producing a None that eventually gets
filtered, it doesn't appear in the parsed config in the first place.

Co-authored-by: Robert Van Wesep <[email protected]>
2021-06-01 16:17:45 +03:00
Saugat Pachhai c7a3a000e0 try skipping local pre-commit hooks (#6090) 2021-06-01 16:03:18 +05:45
dependabot[bot] 782e2aba87 build(deps): bump pylint from 2.8.2 to 2.8.3 (#6087)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.8.2 to 2.8.3.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Changelog](https://github.com/PyCQA/pylint/blob/master/ChangeLog)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.8.2...v2.8.3)

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-06-01 08:49:12 +05:45
Ruslan Kuprieiev 488a6f8076 fs: don't automatically create buckets (#6086)
* fs: azure: don't create bucket

* fs: oss: don't create bucket
2021-06-01 01:29:17 +03:00
Batuhan Taskaya 1fbf0da3df azure: better error messages / allow_anonymous_login option (#5833)
* azure: better error messages / allow_anonymous_login option

* dont pass repo to the initializer
2021-06-01 01:17:03 +03:00
Saugat Pachhai bf5deb08e4 pin networkx to 2.5 (#6085)
I am bit hesitant to add an upper-bound here
as networkx seems to be requiring a lot of
dependencies from next version onwards.
2021-05-31 21:39:48 +03:00
117 changed files with 1881 additions and 1372 deletions
+1
View File
@@ -0,0 +1 @@
* @iterative/dvc
+3 -3
View File
@@ -7,7 +7,7 @@ on:
types: [released, prereleased]
jobs:
deb:
runs-on: ubuntu-16.04
runs-on: ubuntu-18.04
steps:
- uses: actions/[email protected]
- name: Set up Python 3.8
@@ -36,7 +36,7 @@ jobs:
asset_name: dvc_${{ github.event.release.tag_name }}_amd64.deb
asset_content_type: binary/octet-stream
rpm:
runs-on: ubuntu-16.04
runs-on: ubuntu-18.04
steps:
- uses: actions/[email protected]
- name: Set up Python 3.8
@@ -125,7 +125,7 @@ jobs:
asset_name: dvc-${{ github.event.release.tag_name }}.exe
asset_content_type: binary/octet-stream
pip:
runs-on: ubuntu-16.04
runs-on: ubuntu-18.04
steps:
- uses: actions/[email protected]
- name: Set up Python 3.7
+2 -2
View File
@@ -105,7 +105,7 @@ jobs:
--cov-report=xml --cov-report=term
--all ${{ env.extra_test_args }}
- name: upload coverage report
uses: codecov/[email protected].0
uses: codecov/[email protected].2
with:
file: ./coverage.xml
fail_ci_if_error: false
@@ -117,7 +117,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2.1.3
uses: rtCamp/action-slack-notify@v2.2.0
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_COLOR: ${{ job.status }}
+11 -3
View File
@@ -1,14 +1,17 @@
ci:
skip: [pylint, dvc-pre-commit]
repos:
- hooks:
- id: black
language_version: python3
repo: https://github.com/ambv/black
rev: 21.5b1
rev: 21.6b0
- hooks:
- id: isort
language_version: python3
repo: https://github.com/timothycrosley/isort
rev: 5.8.0
rev: 5.9.1
- hooks:
- id: flake8
language_version: python3
@@ -23,8 +26,13 @@ repos:
- id: mypy
language_version: python3
files: ^dvc/
additional_dependencies:
- types-requests
- types-paramiko
- types-tabulate
- types-toml
repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v0.812'
rev: 'v0.902'
- repo: local
hooks:
- id: pylint
+4
View File
@@ -204,6 +204,10 @@ Comparison to related technologies
#. `DAGsHub <https://dagshub.com/>`_ - online service of Git+DVC repositories with pipeline and metrics visualization and
DVC-specific cloud storage.
#. `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.
Contributing
============
+1 -1
View File
@@ -134,7 +134,7 @@ def _cache_is_copy(cache, path_info):
return True
workspace_file = path_info.with_name("." + uuid())
test_cache_file = cache.fs.path_info / ".cache_type_test_file"
test_cache_file = cache.path_info / ".cache_type_test_file"
if not cache.fs.exists(test_cache_file):
cache.makedirs(test_cache_file.parent)
with cache.fs.open(test_cache_file, "wb") as fobj:
+5 -2
View File
@@ -3,6 +3,7 @@ import logging
import os
from dvc.command.base import CmdBaseNoRepo, append_doc_link
from dvc.ui import ui
logger = logging.getLogger(__name__)
@@ -71,7 +72,9 @@ class CmdConfig(CmdBaseNoRepo):
prefix = self._config_file_prefix(
self.args.show_origin, self.config, level
)
logger.info("\n".join(self._format_config(conf, prefix)))
configs = list(self._format_config(conf, prefix))
if configs:
ui.write("\n".join(configs))
return 0
@@ -96,7 +99,7 @@ class CmdConfig(CmdBaseNoRepo):
prefix = self._config_file_prefix(
self.args.show_origin, self.config, level
)
logger.info("{}{}".format(prefix, conf[section][opt]))
ui.write(prefix, conf[section][opt], sep="")
break
return 0
+2 -1
View File
@@ -9,10 +9,11 @@ logger = logging.getLogger(__name__)
class CmdDataBase(CmdBase):
def log_summary(self, stats):
from dvc.ui import ui
from dvc.utils.humanize import get_summary
default_msg = "Everything is up to date."
logger.info(get_summary(stats.items()) or default_msg)
ui.write(get_summary(stats.items()) or default_msg)
class CmdDataPull(CmdDataBase):
+2 -2
View File
@@ -1,7 +1,6 @@
import argparse
import logging
from dvc import prompt
from dvc.command.base import CmdBase, append_doc_link
logger = logging.getLogger(__name__)
@@ -10,6 +9,7 @@ logger = logging.getLogger(__name__)
class CmdDestroy(CmdBase):
def run(self):
from dvc.exceptions import DvcException
from dvc.ui import ui
try:
statement = (
@@ -19,7 +19,7 @@ class CmdDestroy(CmdBase):
"Are you sure you want to continue?"
)
if not self.args.force and not prompt.confirm(statement):
if not self.args.force and not ui.confirm(statement):
raise DvcException(
"cannot destroy without a confirmation from the user."
" Use `-f` to force."
+16 -28
View File
@@ -3,10 +3,9 @@ import json
import logging
import os
import colorama
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.ui import ui
logger = logging.getLogger(__name__)
@@ -18,13 +17,12 @@ def _digest(checksum):
def _show_md(diff, show_hash=False, hide_missing=False):
from dvc.ui import ui
headers = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
rows = []
statuses = ["added", "deleted", "renamed", "modified"]
if not hide_missing:
statuses.append("not in cache")
for status in statuses:
entries = diff.get(status, [])
if not entries:
@@ -44,7 +42,7 @@ def _show_md(diff, show_hash=False, hide_missing=False):
class CmdDiff(CmdBase):
@staticmethod
def _format(diff, hide_missing=False):
def _show_diff(diff, hide_missing=False):
"""
Given a diff structure, generate a string of paths separated
by new lines and grouped together by their state.
@@ -71,15 +69,14 @@ class CmdDiff(CmdBase):
"""
colors = {
"added": colorama.Fore.GREEN,
"modified": colorama.Fore.YELLOW,
"deleted": colorama.Fore.RED,
"renamed": colorama.Fore.GREEN,
"not in cache": colorama.Fore.YELLOW,
"added": "green",
"modified": "yellow",
"deleted": "red",
"renamed": "green",
"not in cache": "yellow",
}
summary = {}
groups = []
states = ["added", "deleted", "renamed", "modified"]
if not hide_missing:
@@ -91,7 +88,8 @@ class CmdDiff(CmdBase):
if not entries:
continue
content = []
header = state.capitalize()
ui.write(f"[{colors[state]}]{header}[/]:", styled=True)
for entry in entries:
path = entry["path"]
@@ -99,7 +97,7 @@ class CmdDiff(CmdBase):
path = f"{path['old']} -> {path['new']}"
checksum = entry.get("hash")
summary[state] += 1 if not path.endswith(os.sep) else 0
content.append(
ui.write(
"{space}{checksum}{separator}{path}".format(
space=" ",
checksum=_digest(checksum) if checksum else "",
@@ -108,14 +106,7 @@ class CmdDiff(CmdBase):
)
)
groups.append(
"{color}{header}{nc}:\n{content}".format(
color=colors[state],
header=state.capitalize(),
nc=colorama.Fore.RESET,
content="\n".join(content),
)
)
ui.write()
if not sum(summary.values()):
return None
@@ -126,9 +117,8 @@ class CmdDiff(CmdBase):
)
if not hide_missing:
fmt += ", {not in cache} not in cache"
groups.append(fmt.format_map(summary))
return "\n\n".join(groups)
ui.write(fmt.format_map(summary))
def run(self):
from dvc.exceptions import DvcException
@@ -155,13 +145,11 @@ class CmdDiff(CmdBase):
diff[key] = entries
if self.args.show_json:
logger.info(json.dumps(diff))
ui.write(json.dumps(diff))
elif self.args.show_md:
logger.info(_show_md(diff, show_hash, hide_missing))
_show_md(diff, show_hash, hide_missing)
elif diff:
output = self._format(diff, hide_missing)
if output:
logger.info(output)
self._show_diff(diff, hide_missing)
except DvcException:
logger.exception("failed to get diff")
+24 -24
View File
@@ -14,6 +14,7 @@ from dvc.command.metrics import DEFAULT_PRECISION
from dvc.command.repro import CmdRepro
from dvc.command.repro import add_arguments as add_repro_arguments
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.ui import ui
from dvc.utils.flatten import flatten
if TYPE_CHECKING:
@@ -450,7 +451,7 @@ class CmdExperimentsShow(CmdBase):
if self.args.show_json:
import json
logger.info(json.dumps(all_experiments, default=_format_json))
ui.write(json.dumps(all_experiments, default=_format_json))
else:
show_experiments(
all_experiments,
@@ -494,7 +495,7 @@ class CmdExperimentsDiff(CmdBase):
if self.args.show_json:
import json
logger.info(json.dumps(diff))
ui.write(json.dumps(diff))
else:
from dvc.compare import show_diff
@@ -502,8 +503,6 @@ class CmdExperimentsDiff(CmdBase):
diffs = [("metrics", "Metric"), ("params", "Param")]
for idx, (key, title) in enumerate(diffs):
if idx:
from dvc.ui import ui
# we are printing tables even in `--quiet` mode
# so we should also be printing the "table" separator
ui.write(force=True)
@@ -537,7 +536,7 @@ class CmdExperimentsRun(CmdRepro):
)
if self.args.reset:
logger.info("Any existing checkpoints will be reset and re-run.")
ui.write("Any existing checkpoints will be reset and re-run.")
results = self.repo.experiments.run(
name=self.args.name,
@@ -554,7 +553,7 @@ class CmdExperimentsRun(CmdRepro):
if self.args.metrics and results:
metrics = self.repo.metrics.show(revs=list(results))
metrics.pop("workspace", None)
logger.info(show_metrics(metrics))
show_metrics(metrics)
return 0
@@ -608,12 +607,13 @@ class CmdExperimentsGC(CmdRepro):
)
if removed:
logger.info(
f"Removed {removed} experiments. To remove unused cache files "
"use 'dvc gc'."
ui.write(
f"Removed {removed} experiments.",
"To remove unused cache files",
"use 'dvc gc'.",
)
else:
logger.info("No experiments to remove.")
ui.write("No experiments to remove.")
return 0
@@ -662,15 +662,15 @@ class CmdExperimentsPush(CmdBase):
run_cache=self.args.run_cache,
)
logger.info(
"Pushed experiment '%s' to Git remote '%s'.",
self.args.experiment,
self.args.git_remote,
ui.write(
f"Pushed experiment '{self.args.experiment}'"
f"to Git remote '{self.args.git_remote}'."
)
if not self.args.push_cache:
logger.info(
"To push cached outputs for this experiment to DVC remote "
"storage, re-run this command without '--no-cache'."
ui.write(
"To push cached outputs",
"for this experiment to DVC remote storage,"
"re-run this command without '--no-cache'.",
)
return 0
@@ -689,15 +689,15 @@ class CmdExperimentsPull(CmdBase):
run_cache=self.args.run_cache,
)
logger.info(
"Pulled experiment '%s' from Git remote '%s'. ",
self.args.experiment,
self.args.git_remote,
ui.write(
f"Pulled experiment '{self.args.experiment}'",
f"from Git remote '{self.args.git_remote}'.",
)
if not self.args.pull_cache:
logger.info(
"To pull cached outputs for this experiment from DVC remote "
"storage, re-run this command without '--no-cache'."
ui.write(
"To pull cached outputs for this experiment"
"from DVC remote storage,"
"re-run this command without '--no-cache'."
)
return 0
+2 -2
View File
@@ -2,8 +2,8 @@ import argparse
import logging
import os
from dvc import prompt
from dvc.command.base import CmdBase, append_doc_link
from dvc.ui import ui
logger = logging.getLogger(__name__)
@@ -44,7 +44,7 @@ class CmdGC(CmdBase):
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
self.repo.gc(
+2 -1
View File
@@ -12,12 +12,13 @@ logger = logging.getLogger(__name__)
class CmdGet(CmdBaseNoRepo):
def _show_url(self):
from dvc.api import get_url
from dvc.ui import ui
try:
url = get_url(
self.args.path, repo=self.args.url, rev=self.args.rev
)
logger.info(url)
ui.write(url, force=True)
except DvcException:
logger.exception("failed to show URL")
return 1
+4 -2
View File
@@ -12,8 +12,10 @@ logger = logging.getLogger(__name__)
def _welcome_message():
from dvc.ui import ui
if analytics.is_enabled():
logger.info(
ui.write(
boxify(
"DVC has enabled anonymous aggregate usage analytics.\n"
"Read the analytics documentation (and how to opt-out) here:\n"
@@ -30,7 +32,7 @@ def _welcome_message():
f"- Star us on GitHub: {fmt_link('https://github.com/iterative/dvc')}"
).format(yellow=colorama.Fore.YELLOW, nc=colorama.Fore.RESET)
logger.info(msg)
ui.write(msg)
class CmdInit(CmdBaseNoRepo):
+2 -4
View File
@@ -1,11 +1,9 @@
import argparse
import logging
import os
from dvc.command import completion
from dvc.command.base import CmdBase, fix_subparsers
logger = logging.getLogger(__name__)
from dvc.ui import ui
class CmdLive(CmdBase):
@@ -17,7 +15,7 @@ class CmdLive(CmdBase):
html_path = self.args.target + ".html"
self.repo.plots.write_html(html_path, plots, metrics)
logger.info(f"\nfile://{os.path.abspath(html_path)}")
ui.write("\nfile://", os.path.abspath(html_path), sep="")
return 0
+3 -2
View File
@@ -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 BadMetricError, DvcException
from dvc.ui import ui
logger = logging.getLogger(__name__)
@@ -32,7 +33,7 @@ class CmdMetricsShow(CmdMetricsBase):
if self.args.show_json:
import json
logger.info(json.dumps(metrics))
ui.write(json.dumps(metrics))
else:
from dvc.compare import show_metrics
@@ -73,7 +74,7 @@ class CmdMetricsDiff(CmdMetricsBase):
if self.args.show_json:
import json
logger.info(json.dumps(diff))
ui.write(json.dumps(diff))
else:
from dvc.compare import show_diff
+2 -1
View File
@@ -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.ui import ui
logger = logging.getLogger(__name__)
@@ -27,7 +28,7 @@ class CmdParamsDiff(CmdBase):
if self.args.show_json:
import json
logger.info(json.dumps(diff))
ui.write(json.dumps(diff))
else:
from dvc.compare import show_diff
+6 -3
View File
@@ -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.ui import ui
from dvc.utils import format_link
logger = logging.getLogger(__name__)
@@ -38,7 +39,7 @@ class CmdPlots(CmdBase):
if self.args.show_vega:
target = self.args.targets[0]
logger.info(plots[target])
ui.write(plots[target])
return 0
rel: str = self.args.out or "plots.html"
@@ -53,13 +54,15 @@ class CmdPlots(CmdBase):
assert path.is_absolute() # as_uri throws ValueError if not absolute
url = path.as_uri()
logger.info(url)
ui.write(url)
if self.args.open:
import webbrowser
opened = webbrowser.open(rel)
if not opened:
logger.error("Failed to open. Please try opening it manually.")
ui.error_write(
"Failed to open. Please try opening it manually."
)
return 1
return 0
+4 -6
View File
@@ -1,12 +1,10 @@
import argparse
import logging
from dvc.command.base import append_doc_link, fix_subparsers
from dvc.command.config import CmdConfig
from dvc.ui import ui
from dvc.utils import format_link
logger = logging.getLogger(__name__)
class CmdRemote(CmdConfig):
def __init__(self, args):
@@ -27,7 +25,7 @@ class CmdRemoteAdd(CmdRemote):
from dvc.config import ConfigError
if self.args.default:
logger.info(f"Setting '{self.args.name}' as a default remote.")
ui.write(f"Setting '{self.args.name}' as a default remote.")
with self.config.edit(self.args.level) as conf:
if self.args.name in conf["remote"] and not self.args.force:
@@ -90,7 +88,7 @@ class CmdRemoteDefault(CmdRemote):
try:
print(conf["core"]["remote"])
except KeyError:
logger.info("No default remote set")
ui.write("No default remote set")
return 1
else:
with self.config.edit(self.args.level) as conf:
@@ -116,7 +114,7 @@ class CmdRemoteList(CmdRemote):
def run(self):
conf = self.config.read(self.args.level)
for name, conf in conf["remote"].items():
logger.info("{}\t{}".format(name, conf["url"]))
ui.write(name, conf["url"], sep="\t")
return 0
+4 -5
View File
@@ -1,20 +1,19 @@
import argparse
import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.command.status import CmdDataStatus
logger = logging.getLogger(__name__)
class CmdRepro(CmdBase):
def run(self):
from dvc.ui import ui
stages = self.repo.reproduce(**self._repro_kwargs)
if len(stages) == 0:
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
ui.write(CmdDataStatus.UP_TO_DATE_MSG)
else:
logger.info(
ui.write(
"Use `dvc push` to send your updates to " "remote storage."
)
+2 -1
View File
@@ -10,8 +10,9 @@ logger = logging.getLogger(__name__)
class CmdRoot(CmdBaseNoRepo):
def run(self):
from dvc.repo import Repo
from dvc.ui import ui
logger.info(relpath(Repo.find_root()))
ui.write(relpath(Repo.find_root()))
return 0
+5 -5
View File
@@ -2,6 +2,7 @@ import logging
from dvc.command.data_sync import CmdDataBase
from dvc.exceptions import DvcException
from dvc.ui import ui
from dvc.utils import format_link
logger = logging.getLogger(__name__)
@@ -26,7 +27,7 @@ class CmdDataStatus(CmdDataBase):
ind = indent * self.STATUS_INDENT
if isinstance(status, str):
logger.info(f"{ind}{status}")
ui.write(f"{ind}{status}")
return
if isinstance(status, list):
@@ -38,14 +39,13 @@ class CmdDataStatus(CmdDataBase):
for key, value in status.items():
if isinstance(value, str):
logger.info("{}{}{}".format(ind, self._normalize(value), key))
ui.write("{}{}{}".format(ind, self._normalize(value), key))
elif value:
logger.info(f"{ind}{key}:")
ui.write(f"{ind}{key}:")
self._show(value, indent + 1)
def run(self):
from dvc.repo import lock_repo
from dvc.ui import ui
indent = 1 if self.args.cloud else 0
@@ -72,7 +72,7 @@ class CmdDataStatus(CmdDataBase):
if self.args.show_json:
import json
logger.info(json.dumps(st))
ui.write(json.dumps(st))
return 0
if st:
+2 -1
View File
@@ -2,6 +2,7 @@ import argparse
import logging
from dvc.command.base import CmdBaseNoRepo, append_doc_link
from dvc.ui import ui
logger = logging.getLogger(__name__)
@@ -11,7 +12,7 @@ class CmdVersion(CmdBaseNoRepo):
from dvc.info import get_dvc_info
dvc_info = get_dvc_info()
logger.info(dvc_info)
ui.write(dvc_info, force=True)
return 0
+4 -2
View File
@@ -93,7 +93,7 @@ HTTP_COMMON = {
"user": str,
"password": str,
"ask_password": Bool,
"ssl_verify": Bool,
"ssl_verify": Any(Bool, str),
"method": str,
}
WEBDAV_COMMON = {
@@ -104,6 +104,7 @@ WEBDAV_COMMON = {
"cert_path": str,
"key_path": str,
"timeout": Coerce(int),
"ssl_verify": Any(Bool, str),
}
SCHEMA = {
@@ -145,7 +146,7 @@ SCHEMA = {
"session_token": str,
Optional("listobjects", default=False): Bool, # obsoleted
Optional("use_ssl", default=True): Bool,
Optional("ssl_verify", default=True): Bool,
"ssl_verify": Any(Bool, str),
"sse": str,
"sse_kms_key_id": str,
"acl": str,
@@ -188,6 +189,7 @@ SCHEMA = {
"tenant_id": str,
"client_id": str,
"client_secret": str,
"allow_anonymous_login": bool,
**REMOTE_COMMON,
},
"oss": {
+53 -32
View File
@@ -1,6 +1,11 @@
"""Manages dvc remotes that user can use with push/pull/status commands."""
import logging
from typing import TYPE_CHECKING, Iterable, Optional
if TYPE_CHECKING:
from dvc.objects.file import HashFile
from dvc.remote.base import Remote
logger = logging.getLogger(__name__)
@@ -19,7 +24,11 @@ class DataCloud:
def __init__(self, repo):
self.repo = repo
def get_remote(self, name=None, command="<command>"):
def get_remote(
self,
name: Optional[str] = None,
command: str = "<command>",
) -> "Remote":
from dvc.config import NoRemoteError
if not name:
@@ -48,76 +57,88 @@ class DataCloud:
return get_remote(self.repo, name=name)
def push(self, cache, jobs=None, remote=None, show_checksums=False):
def push(
self,
objs: Iterable["HashFile"],
jobs: Optional[int] = None,
remote: Optional[str] = None,
show_checksums: bool = False,
):
"""Push data items in a cloud-agnostic way.
Args:
cache (NamedCache): named checksums to push to the cloud.
jobs (int): number of jobs that can be running simultaneously.
remote (dvc.remote.base.BaseRemote): optional remote to push to.
objs: objects to push to the cloud.
jobs: number of jobs that can be running simultaneously.
remote: optional remote to push to.
By default remote from core.remote config option is used.
show_checksums (bool): show checksums instead of file names in
show_checksums: show checksums instead of file names in
information messages.
"""
remote = self.get_remote(remote, "push")
remote_obj = self.get_remote(remote, "push")
return remote.push(
return remote_obj.push(
self.repo.odb.local,
cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
)
def pull(self, cache, jobs=None, remote=None, show_checksums=False):
def pull(
self,
objs: Iterable["HashFile"],
jobs: Optional[int] = None,
remote: Optional[str] = None,
show_checksums: bool = False,
):
"""Pull data items in a cloud-agnostic way.
Args:
cache (NamedCache): named checksums to pull from the cloud.
jobs (int): number of jobs that can be running simultaneously.
remote (dvc.remote.base.BaseRemote): optional remote to pull from.
objs: objects to pull from the cloud.
jobs: number of jobs that can be running simultaneously.
remote: optional remote to pull from.
By default remote from core.remote config option is used.
show_checksums (bool): show checksums instead of file names in
show_checksums: show checksums instead of file names in
information messages.
"""
remote = self.get_remote(remote, "pull")
remote_obj = self.get_remote(remote, "pull")
return remote.pull(
return remote_obj.pull(
self.repo.odb.local,
cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
)
def status(
self,
cache,
jobs=None,
remote=None,
show_checksums=False,
log_missing=True,
objs: Iterable["HashFile"],
jobs: Optional[int] = None,
remote: Optional[str] = None,
show_checksums: bool = False,
log_missing: bool = True,
):
"""Check status of data items in a cloud-agnostic way.
Args:
cache (NamedCache): named checksums to check status for.
jobs (int): number of jobs that can be running simultaneously.
remote (dvc.remote.base.BaseRemote): optional remote to compare
objs: objects to check status for.
jobs: number of jobs that can be running simultaneously.
remote: optional remote to compare
cache to. By default remote from core.remote config option
is used.
show_checksums (bool): show checksums instead of file names in
show_checksums: show checksums instead of file names in
information messages.
log_missing (bool): log warning messages if file doesn't exist
log_missing: log warning messages if file doesn't exist
neither in cache, neither in cloud.
"""
remote = self.get_remote(remote, "status")
return remote.status(
remote_obj = self.get_remote(remote, "status")
return remote_obj.status(
self.repo.odb.local,
cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
log_missing=log_missing,
)
def get_url_for(self, remote, checksum):
remote = self.get_remote(remote)
return str(remote.odb.hash_to_path_info(checksum))
remote_obj = self.get_remote(remote)
return str(remote_obj.odb.hash_to_path_info(checksum))
+110 -46
View File
@@ -1,9 +1,17 @@
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, Optional, Set
from voluptuous import Required
from dvc.path_info import PathInfo
from .base import Dependency
if TYPE_CHECKING:
from dvc.objects.db.base import ObjectDB
from dvc.objects.file import HashFile
class RepoDependency(Dependency):
PARAM_REPO = "repo"
@@ -21,6 +29,7 @@ class RepoDependency(Dependency):
def __init__(self, def_repo, stage, *args, **kwargs):
self.def_repo = def_repo
self._staged_objs: Dict[str, "HashFile"] = {}
super().__init__(stage, *args, **kwargs)
def _parse_path(self, fs, path_info):
@@ -30,37 +39,12 @@ class RepoDependency(Dependency):
def is_in_repo(self):
return False
@property
def repo_pair(self):
d = self.def_repo
rev = d.get(self.PARAM_REV_LOCK) or d.get(self.PARAM_REV)
return d[self.PARAM_URL], rev
def __str__(self):
return "{} ({})".format(self.def_path, self.def_repo[self.PARAM_URL])
def _make_repo(self, *, locked=True, **kwargs):
from dvc.external_repo import external_repo
d = self.def_repo
rev = (d.get("rev_lock") if locked else None) or d.get("rev")
return external_repo(d["url"], rev=rev, **kwargs)
def _get_hash(self, locked=True):
from dvc.objects.stage import stage
with self._make_repo(locked=locked) as repo:
path_info = PathInfo(repo.root_dir) / self.def_path
return stage(
self.repo.odb.local,
path_info,
repo.repo_fs,
self.repo.odb.local.fs.PARAM_CHECKSUM,
).hash_info
def workspace_status(self):
current = self._get_hash(locked=True)
updated = self._get_hash(locked=False)
current = self.get_obj(locked=True).hash_info
updated = self.get_obj(locked=False).hash_info
if current != updated:
return {str(self): "update available"}
@@ -78,31 +62,21 @@ class RepoDependency(Dependency):
def download(self, to, jobs=None):
from dvc.checkout import checkout
from dvc.config import NoRemoteError
from dvc.exceptions import NoOutputOrStageError
from dvc.objects import save
from dvc.objects.stage import stage
from dvc.objects.db.git import GitObjectDB
from dvc.repo.fetch import fetch_from_odb
odb = self.repo.odb.local
with self._make_repo(cache_dir=odb.cache_dir) as repo:
if self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
path_info = PathInfo(repo.root_dir) / self.def_path
try:
repo.fetch([path_info.fspath], jobs=jobs, recursive=True)
except (NoOutputOrStageError, NoRemoteError):
pass
obj = stage(
odb, path_info, repo.repo_fs, odb.fs.PARAM_CHECKSUM, jobs=jobs
)
save(odb, obj, jobs=jobs)
for odb, objs in self.get_used_objs().items():
if not isinstance(odb, GitObjectDB):
fetch_from_odb(self.repo, odb, objs, jobs=jobs)
obj = self.get_obj()
save(self.repo.odb.local, obj, jobs=jobs)
checkout(
to.path_info,
to.fs,
obj,
odb,
self.repo.odb.local,
dvcignore=None,
state=self.repo.state,
)
@@ -110,7 +84,6 @@ class RepoDependency(Dependency):
def update(self, rev=None):
if rev:
self.def_repo[self.PARAM_REV] = rev
with self._make_repo(locked=False) as repo:
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
@@ -119,3 +92,94 @@ class RepoDependency(Dependency):
# origin project url and rev_lock, and it makes RepoDependency
# immutable, hence its impossible for checksum to change.
return False
def get_used_objs(
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
from dvc.config import NoRemoteError
from dvc.exceptions import NoOutputOrStageError
from dvc.objects.db.git import GitObjectDB
from dvc.objects.stage import stage
local_odb = self.repo.odb.local
locked = kwargs.pop("locked", True)
with self._make_repo(
locked=locked, cache_dir=local_odb.cache_dir
) as repo:
used_objs = defaultdict(set)
rev = repo.get_rev()
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = rev
path_info = PathInfo(repo.root_dir) / str(self.def_path)
try:
for odb, objs in repo.used_objs(
[os.fspath(path_info)],
force=True,
jobs=kwargs.get("jobs"),
recursive=True,
).items():
if odb is None:
odb = repo.cloud.get_remote().odb
self._check_circular_import(odb)
used_objs[odb].update(objs)
except (NoRemoteError, NoOutputOrStageError):
pass
staged_obj = stage(
local_odb,
path_info,
repo.repo_fs,
local_odb.fs.PARAM_CHECKSUM,
)
self._staged_objs[rev] = staged_obj
git_odb = GitObjectDB(repo.repo_fs, repo.root_dir)
used_objs[git_odb].add(staged_obj)
return used_objs
def _check_circular_import(self, odb):
from dvc.exceptions import CircularImportError
from dvc.fs.repo import RepoFileSystem
if not odb or not isinstance(odb.fs, RepoFileSystem):
return
self_url = self.repo.url or self.repo.root_dir
if odb.fs.repo_url is not None and odb.fs.repo_url == self_url:
raise CircularImportError(self, odb.fs.repo_url, self_url)
def get_obj(self, filter_info=None, **kwargs):
from dvc.objects.stage import stage
odb = self.repo.odb.local
locked = kwargs.pop("locked", True)
with self._make_repo(locked=locked, cache_dir=odb.cache_dir) as repo:
rev = repo.get_rev()
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = rev
obj = self._staged_objs.get(rev)
if obj is not None:
return obj
path_info = PathInfo(repo.root_dir) / str(self.def_path)
obj = stage(
odb,
path_info,
repo.repo_fs,
odb.fs.PARAM_CHECKSUM,
)
self._staged_objs[rev] = obj
return obj
def _make_repo(self, locked=True, **kwargs):
from dvc.external_repo import external_repo
d = self.def_repo
rev = self._get_rev(locked=locked)
return external_repo(d[self.PARAM_URL], rev=rev, **kwargs)
def _get_rev(self, locked=True):
d = self.def_repo
return (d.get(self.PARAM_REV_LOCK) if locked else None) or d.get(
self.PARAM_REV
)
+1
View File
@@ -6,3 +6,4 @@ DVCLIVE_PATH = "DVCLIVE_PATH"
DVCLIVE_SUMMARY = "DVCLIVE_SUMMARY"
DVCLIVE_HTML = "DVCLIVE_HTML"
DVCLIVE_RESUME = "DVCLIVE_RESUME"
DVC_IGNORE_ISATTY = "DVC_IGNORE_ISATTY"
+8
View File
@@ -384,3 +384,11 @@ class CacheLinkError(DvcException):
)
super().__init__(msg)
self.path_infos = path_infos
class CircularImportError(DvcException):
def __init__(self, dep, a, b):
super().__init__(
f"'{dep}' contains invalid circular import. "
f"DVC repo '{a}' already imports from '{b}'."
)
+9 -3
View File
@@ -12,8 +12,7 @@ from .local import LocalFileSystem
from .oss import OSSFileSystem
from .s3 import S3FileSystem
from .ssh import SSHFileSystem
from .webdav import WebDAVFileSystem
from .webdavs import WebDAVSFileSystem
from .webdav import WebDAVFileSystem, WebDAVSFileSystem
from .webhdfs import WebHDFSFileSystem
FS_MAP = {
@@ -106,4 +105,11 @@ def get_cloud_fs(repo, **kwargs):
if cls == GDriveFileSystem and repo:
remote_conf["gdrive_credentials_tmp_dir"] = repo.tmp_dir
return cls, remote_conf
url = remote_conf.pop("url")
path_info = cls.PATH_CLS(
cls._strip_protocol(url) # pylint:disable=protected-access
)
extras = cls._get_kwargs_from_urls(url) # pylint:disable=protected-access
conf = {**extras, **remote_conf} # remote config takes priority
return cls, conf, path_info
+46 -33
View File
@@ -5,7 +5,8 @@ import sys
import threading
from contextlib import contextmanager
from funcy import cached_property, wrap_prop
from fsspec.utils import infer_storage_options
from funcy import cached_property, memoize, wrap_prop
from dvc.exceptions import DvcException
from dvc.path_info import CloudURLInfo
@@ -67,6 +68,19 @@ class AzureAuthError(DvcException):
pass
@memoize
def _az_config():
# NOTE: ideally we would've used get_default_cli().config from
# azure.cli.core, but azure-cli-core has a lot of conflicts with other
# dependencies. So instead we are just use knack directly
from knack.config import CLIConfig
config_dir = os.getenv(
"AZURE_CONFIG_DIR", os.path.expanduser(os.path.join("~", ".azure"))
)
return CLIConfig(config_dir=config_dir, config_env_var_prefix="AZURE")
# pylint:disable=abstract-method
class AzureFileSystem(ObjectFSWrapper):
scheme = Schemes.AZURE
@@ -79,19 +93,21 @@ class AzureFileSystem(ObjectFSWrapper):
"azure-identity": "azure.identity",
}
def __init__(self, **config):
super().__init__(**config)
@classmethod
def _strip_protocol(cls, path: str):
bucket = infer_storage_options(path).get("host")
if bucket:
return path
url = config.get("url")
self.path_info = self.PATH_CLS(url)
self.bucket = self.path_info.bucket
bucket = _az_config().get("storage", "container_name", None)
return f"azure://{bucket}"
if not self.bucket:
container = self._az_config.get("storage", "container_name", None)
url = f"azure://{container}"
self.path_info = self.PATH_CLS(url)
self.bucket = self.path_info.bucket
@staticmethod
def _get_kwargs_from_urls(urlpath):
ops = infer_storage_options(urlpath)
if "host" in ops:
return {"bucket": ops["host"]}
return {}
def _prepare_credentials(self, **config):
from azure.identity.aio import DefaultAzureCredential
@@ -102,23 +118,36 @@ class AzureFileSystem(ObjectFSWrapper):
login_info = {}
login_info["connection_string"] = config.get(
"connection_string",
self._az_config.get("storage", "connection_string", None),
_az_config().get("storage", "connection_string", None),
)
login_info["account_name"] = config.get(
"account_name", self._az_config.get("storage", "account", None)
"account_name", _az_config().get("storage", "account", None)
)
login_info["account_key"] = config.get(
"account_key", self._az_config.get("storage", "key", None)
"account_key", _az_config().get("storage", "key", None)
)
login_info["sas_token"] = config.get(
"sas_token", self._az_config.get("storage", "sas_token", None)
"sas_token", _az_config().get("storage", "sas_token", None)
)
login_info["tenant_id"] = config.get("tenant_id")
login_info["client_id"] = config.get("client_id")
login_info["client_secret"] = config.get("client_secret")
if not any(
if not (login_info["account_name"] or login_info["connection_string"]):
raise AzureAuthError(
"Authentication to Azure Blob Storage requires either "
"account_name or connection_string.\nLearn more about "
"configuration settings at "
+ format_link("https://man.dvc.org/remote/modify")
)
any_secondary = any(
value for key, value in login_info.items() if key != "account_name"
)
if (
login_info["account_name"]
and not any_secondary
and not config.get("allow_anonymous_login", False)
):
login_info["credential"] = DefaultAzureCredential(
exclude_interactive_browser_credential=False
@@ -146,18 +175,6 @@ class AzureFileSystem(ObjectFSWrapper):
self.login_method = login_method
return login_info
@cached_property
def _az_config(self):
# NOTE: ideally we would've used get_default_cli().config from
# azure.cli.core, but azure-cli-core has a lot of conflicts with other
# dependencies. So instead we are just use knack directly
from knack.config import CLIConfig
config_dir = os.getenv(
"AZURE_CONFIG_DIR", os.path.expanduser(os.path.join("~", ".azure"))
)
return CLIConfig(config_dir=config_dir, config_env_var_prefix="AZURE")
@wrap_prop(threading.Lock())
@cached_property
def fs(self):
@@ -167,10 +184,6 @@ class AzureFileSystem(ObjectFSWrapper):
try:
with _temp_event_loop():
file_system = AzureBlobFileSystem(**self.fs_args)
if self.bucket not in [
container.rstrip("/") for container in file_system.ls("/")
]:
file_system.mkdir(self.bucket)
except (ValueError, AzureError) as e:
raise AzureAuthError(
f"Authentication to Azure Blob Storage via {self.login_method}"
+21 -6
View File
@@ -55,11 +55,17 @@ class BaseFileSystem:
def __init__(self, **kwargs):
self._check_requires(**kwargs)
self.path_info = None
self.jobs = kwargs.get("jobs") or self._JOBS
self.hash_jobs = kwargs.get("checksum_jobs") or self.HASH_JOBS
@classmethod
def _strip_protocol(cls, path: str):
return path
@staticmethod
def _get_kwargs_from_urls(urlpath): # pylint:disable=unused-argument
return {}
@classmethod
def get_missing_deps(cls):
import importlib
@@ -164,7 +170,7 @@ class BaseFileSystem:
def ls(self, path_info, detail=False):
raise RemoteActionNotImplemented("ls", self.scheme)
def find(self, path_info, detail=False):
def find(self, path_info, detail=False, prefix=None):
raise RemoteActionNotImplemented("find", self.scheme)
def is_empty(self, path_info):
@@ -229,14 +235,23 @@ class BaseFileSystem:
no_progress_bar=no_progress_bar,
)
def upload_fobj(self, fobj, to_info, no_progress_bar=False, **pbar_args):
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, **pbar_args
fobj,
"read",
disable=no_progress_bar,
bytes=True,
total=size,
**pbar_args,
) as wrapped:
self._upload_fobj(wrapped, to_info) # pylint: disable=no-member
self._upload_fobj( # pylint: disable=no-member
wrapped, to_info, size=size
)
def download(
self,
+20 -8
View File
@@ -11,6 +11,8 @@ from .base import BaseFileSystem
# pylint: disable=no-member
class FSSpecWrapper(BaseFileSystem):
TRAVERSE_PREFIX_LEN = 2
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.fs_args = {"skip_instance_cache": True}
@@ -94,7 +96,8 @@ class FSSpecWrapper(BaseFileSystem):
files = self.fs.ls(path, detail=detail)
yield from self._strip_buckets(files, detail=detail)
def find(self, path_info, detail=False):
# pylint: disable=unused-argument
def find(self, path_info, detail=False, prefix=None):
path = self._with_bucket(path_info)
files = self.fs.find(path, detail=detail)
if detail:
@@ -103,7 +106,7 @@ class FSSpecWrapper(BaseFileSystem):
yield from self._strip_buckets(files, detail=detail)
def walk_files(self, path_info, **kwargs):
for file in self.find(path_info):
for file in self.find(path_info, **kwargs):
yield path_info.replace(path=file)
def remove(self, path_info):
@@ -115,20 +118,20 @@ class FSSpecWrapper(BaseFileSystem):
info["name"] = self._strip_bucket(info["name"])
return info
def _upload_fobj(self, fobj, to_info):
def _upload_fobj(self, fobj, to_info, size=None):
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
):
total = os.path.getsize(from_file)
size = os.path.getsize(from_file)
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
self._with_bucket(to_info),
size=size,
desc=name,
total=total,
no_progress_bar=no_progress_bar,
)
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
@@ -153,6 +156,8 @@ class FSSpecWrapper(BaseFileSystem):
# pylint: disable=abstract-method
class ObjectFSWrapper(FSSpecWrapper):
TRAVERSE_PREFIX_LEN = 3
def _isdir(self, path_info):
# Directory in object storages are interpreted differently
# among different fsspec providers, so this logic is a temporary
@@ -167,9 +172,16 @@ class ObjectFSWrapper(FSSpecWrapper):
and entry["name"].endswith("/")
)
def find(self, path_info, detail=False):
path = self._with_bucket(path_info)
files = self.fs.find(path, detail=detail)
def find(self, path_info, detail=False, prefix=None):
if prefix is not None:
path = self._with_bucket(path_info.parent)
files = self.fs.find(
path, detail=detail, prefix=path_info.parts[-1]
)
else:
path = self._with_bucket(path_info)
files = self.fs.find(path, detail=detail)
if detail:
files = files.values()
+13 -4
View File
@@ -142,6 +142,10 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
)
)
@staticmethod
def _get_kwargs_from_urls(urlpath):
return {"url": urlpath}
def _validate_config(self):
# Validate Service Account configuration
if (
@@ -534,7 +538,7 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
query = f"({query}) and trashed=false"
return self._gdrive_list(query)
def find(self, path_info, detail=False):
def find(self, path_info, detail=False, prefix=None):
root_path = path_info.path
seen_paths = set()
@@ -614,22 +618,27 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
gdrive_file.FetchMetadata(fields="fileSize")
return {"size": gdrive_file.get("fileSize")}
def _upload_fobj(self, fobj, to_info):
def _upload_fobj(self, fobj, to_info, **kwargs):
dirname = to_info.parent
assert dirname
parent_id = self._get_item_id(dirname, create=True)
self._gdrive_upload_fobj(fobj, parent_id, to_info.name)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
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=os.path.getsize(from_file),
no_progress_bar=no_progress_bar,
desc=name or to_info.name,
total=os.path.getsize(from_file),
)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
-6
View File
@@ -17,12 +17,6 @@ class GSFileSystem(ObjectFSWrapper):
PARAM_CHECKSUM = "etag"
DETAIL_FIELDS = frozenset(("etag", "size"))
def __init__(self, **config):
super().__init__(**config)
url = config.get("url", "gs://")
self.path_info = self.PATH_CLS(url)
def _prepare_credentials(self, **config):
login_info = {"consistency": None}
login_info["project"] = config.get("projectname")
+11 -17
View File
@@ -6,7 +6,6 @@ import shutil
import subprocess
from collections import deque
from contextlib import closing, contextmanager
from urllib.parse import urlparse
from dvc.hash_info import HashInfo
from dvc.progress import Tqdm
@@ -68,24 +67,19 @@ class HDFSFileSystem(BaseFileSystem):
def __init__(self, **config):
super().__init__(**config)
self.path_info = None
url = config.get("url")
if not url:
return
parsed = urlparse(url)
user = parsed.username or config.get("user")
self.path_info = self.PATH_CLS.from_parts(
scheme=self.scheme,
host=parsed.hostname,
user=user,
port=parsed.port,
path=parsed.path,
)
self.host = config["host"]
self.user = config.get("user")
self.port = config.get("port")
self.kerb_ticket = config.get("kerb_ticket")
@staticmethod
def _get_kwargs_from_urls(urlpath):
from fsspec.implementations.hdfs import PyArrowHDFS
# pylint:disable=protected-access
return PyArrowHDFS._get_kwargs_from_urls(urlpath)
def hdfs(self, path_info):
import pyarrow.fs
@@ -236,7 +230,7 @@ class HDFSFileSystem(BaseFileSystem):
size=self.getsize(path_info),
)
def _upload_fobj(self, fobj, to_info):
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)
+7 -12
View File
@@ -2,6 +2,7 @@ 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
@@ -39,13 +40,7 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def __init__(self, **config):
super().__init__(**config)
url = config.get("url")
if url:
self.path_info = self.PATH_CLS(url)
self.user = config.get("user", None)
self.host = self.path_info.host
else:
self.path_info = None
self.user = config.get("user", None)
self.auth = config.get("auth", None)
self.custom_auth_header = config.get("custom_auth_header", None)
@@ -55,12 +50,12 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
self.ssl_verify = config.get("ssl_verify", True)
self.method = config.get("method", "POST")
def _auth_method(self):
def _auth_method(self, url):
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
if self.auth:
if self.ask_password and self.password is None:
self.password = ask_password(self.host, self.user)
self.password = ask_password(urlparse(url).hostname, self.user)
if self.auth == "basic":
return HTTPBasicAuth(self.user, self.password)
if self.auth == "digest":
@@ -103,7 +98,7 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
res = self._session.request(
method,
url,
auth=self._auth_method(),
auth=self._auth_method(url),
headers=self.headers,
**kwargs,
)
@@ -152,7 +147,7 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
size = self._content_length(resp)
return {"etag": etag, "size": size}
def _upload_fobj(self, fobj, to_info):
def _upload_fobj(self, fobj, to_info, **kwargs):
def chunks(fobj):
while True:
chunk = fobj.read(self.CHUNK_SIZE)
@@ -189,9 +184,9 @@ class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
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,
total=None if no_progress_bar else os.path.getsize(from_file),
)
@staticmethod
+4 -4
View File
@@ -25,8 +25,6 @@ class LocalFileSystem(BaseFileSystem):
super().__init__(**config)
self.fs = LocalFS()
url = config.get("url")
self.path_info = self.PATH_CLS(url) if url else None
@staticmethod
def open(path_info, mode="r", encoding=None, **kwargs):
@@ -34,7 +32,9 @@ 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)
# NOTE: os.fspath could be removed after fsspec 2021.06.1
# https://github.com/intake/filesystem_spec/issues/666
return self.fs.exists(os.fspath(path_info))
def isfile(self, path_info) -> bool:
return os.path.isfile(path_info)
@@ -105,7 +105,7 @@ class LocalFileSystem(BaseFileSystem):
self.remove(tmp_info)
raise
def _upload_fobj(self, fobj, to_info):
def _upload_fobj(self, fobj, to_info, **kwargs):
self.makedirs(to_info.parent)
tmp_info = to_info.parent / tmp_fname("")
try:
+16 -26
View File
@@ -42,10 +42,6 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def __init__(self, **config):
super().__init__(**config)
url = config.get("url")
self.path_info = self.PATH_CLS(url) if url else None
self.bucket = self.path_info.bucket
self.endpoint = config.get("oss_endpoint") or os.getenv("OSS_ENDPOINT")
self.key_id = (
@@ -68,25 +64,17 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
logger.debug(f"key id: {self.key_id}")
logger.debug(f"key secret: {self.key_secret}")
auth = oss2.Auth(self.key_id, self.key_secret)
bucket = oss2.Bucket(auth, self.endpoint, self.bucket)
return oss2.Auth(self.key_id, self.key_secret)
# Ensure bucket exists
try:
bucket.get_bucket_info()
except oss2.exceptions.NoSuchBucket:
bucket.create_bucket(
oss2.BUCKET_ACL_PUBLIC_READ,
oss2.models.BucketCreateConfig(
oss2.BUCKET_STORAGE_CLASS_STANDARD
),
)
return bucket
def _get_bucket(self, bucket):
import oss2
return oss2.Bucket(self.oss_service, self.endpoint, bucket)
def _generate_download_url(self, path_info, expires=3600):
assert path_info.bucket == self.bucket
return self.oss_service.sign_url("GET", path_info.path, expires)
return self._get_bucket(path_info.bucket).sign_url(
"GET", path_info.path, expires
)
def exists(self, path_info) -> bool:
paths = self._list_paths(path_info)
@@ -96,7 +84,7 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
import oss2
for blob in oss2.ObjectIterator(
self.oss_service, prefix=path_info.path
self._get_bucket(path_info.bucket), prefix=path_info.path
):
yield blob.key
@@ -114,16 +102,17 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
raise NotImplementedError
logger.debug(f"Removing oss://{path_info}")
self.oss_service.delete_object(path_info.path)
self._get_bucket(path_info.bucket).delete_object(path_info.path)
def _upload_fobj(self, fobj, to_info):
self.oss_service.put_object(to_info.path, fobj)
def _upload_fobj(self, fobj, to_info, **kwargs):
self._get_bucket(to_info.bucket).put_object(to_info.path, fobj)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
self.oss_service.put_object_from_file(
bucket = self._get_bucket(to_info.bucket)
bucket.put_object_from_file(
to_info.path, from_file, progress_callback=pbar.update_to
)
@@ -133,8 +122,9 @@ class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
import oss2
bucket = self._get_bucket(from_info.bucket)
oss2.resumable_download(
self.oss_service,
bucket,
from_info.path,
to_file,
progress_callback=pbar.update_to,
+6
View File
@@ -64,6 +64,12 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
if hasattr(repo, "dvc_dir"):
self._dvcfss[repo.root_dir] = DvcFileSystem(repo=repo)
@property
def repo_url(self):
if self._main_repo is None:
return None
return self._main_repo.url
def _get_repo(self, path: str) -> Optional["Repo"]:
"""Returns repo that the path falls in, using prefix.
+1 -7
View File
@@ -29,12 +29,6 @@ class BaseS3FileSystem(ObjectFSWrapper):
"grant_write_acp": "GrantWriteACP",
}
def __init__(self, **config):
super().__init__(**config)
url = config.get("url", "s3://")
self.path_info = self.PATH_CLS(url)
_TRANSFER_CONFIG_ALIASES = {
"max_queue_size": "max_io_queue",
"max_concurrent_requests": "max_concurrency",
@@ -103,7 +97,7 @@ class BaseS3FileSystem(ObjectFSWrapper):
client = login_info["client_kwargs"]
client["region_name"] = config.get("region")
client["endpoint_url"] = config.get("endpointurl")
client["verify"] = config.get("ssl_verify", True)
client["verify"] = config.get("ssl_verify")
# encryptions
additional = login_info["s3_additional_kwargs"]
+26 -36
View File
@@ -5,7 +5,6 @@ import os
import shutil
import threading
from contextlib import closing, contextmanager
from urllib.parse import urlparse
from funcy import first, memoize, silent, wrap_with
@@ -48,34 +47,19 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def __init__(self, **config):
super().__init__(**config)
url = config.get("url")
if url:
parsed = urlparse(url)
user_ssh_config = self._load_user_ssh_config(parsed.hostname)
user_ssh_config = self._load_user_ssh_config(config["host"])
host = user_ssh_config.get("hostname", parsed.hostname)
user = (
config.get("user")
or parsed.username
or user_ssh_config.get("user")
or getpass.getuser()
)
port = (
config.get("port")
or parsed.port
or self._try_get_ssh_config_port(user_ssh_config)
or self.DEFAULT_PORT
)
self.path_info = self.PATH_CLS.from_parts(
scheme=self.scheme,
host=host,
user=user,
port=port,
path=parsed.path,
)
else:
self.path_info = None
user_ssh_config = {}
self.host = user_ssh_config.get("hostname", config["host"])
self.user = (
config.get("user")
or user_ssh_config.get("user")
or getpass.getuser()
)
self.port = (
config.get("port")
or self._try_get_ssh_config_port(user_ssh_config)
or self.DEFAULT_PORT
)
self.keyfile = config.get(
"keyfile"
@@ -93,6 +77,16 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
self.sock = None
self.allow_agent = config.get("allow_agent", True)
@staticmethod
def _get_kwargs_from_urls(urlpath):
from fsspec.implementations.sftp import SFTPFileSystem
# pylint:disable=protected-access
kwargs = SFTPFileSystem._get_kwargs_from_urls(urlpath)
if "username" in kwargs:
kwargs["user"] = kwargs.pop("username")
return kwargs
@staticmethod
def ssh_config_filename():
return os.path.expanduser(os.path.join("~", ".ssh", "config"))
@@ -120,17 +114,13 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def _try_get_ssh_config_keyfile(user_ssh_config):
return first(user_ssh_config.get("identityfile") or ())
def ensure_credentials(self, path_info=None):
if path_info is None:
path_info = self.path_info
def ensure_credentials(self):
# NOTE: we use the same password regardless of the server :(
if self.ask_password and self.password is None:
host, user, port = path_info.host, path_info.user, path_info.port
self.password = ask_password(host, user, port)
self.password = ask_password(self.host, self.user, self.port)
def ssh(self, path_info):
self.ensure_credentials(path_info)
self.ensure_credentials()
from .connection import SSHConnection
@@ -247,7 +237,7 @@ class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
with self.ssh(path_info) as ssh:
return ssh.info(path_info.path)
def _upload_fobj(self, fobj, to_info):
def _upload_fobj(self, fobj, to_info, **kwargs):
self.makedirs(to_info.parent)
with self.open(to_info, mode="wb") as fdest:
shutil.copyfileobj(fobj, fdest)
+64 -233
View File
@@ -1,270 +1,101 @@
import io
import logging
import os
import threading
from collections import deque
from functools import lru_cache
from funcy import cached_property, nullcontext, wrap_prop
from funcy import cached_property, wrap_prop
from dvc.config import ConfigError
from dvc.exceptions import DvcException
from dvc.path_info import HTTPURLInfo, WebDAVURLInfo
from dvc.progress import Tqdm
from dvc.path_info import WebDAVURLInfo
from dvc.scheme import Schemes
from .base import BaseFileSystem
from .fsspec_wrapper import FSSpecWrapper
from .http import ask_password
logger = logging.getLogger(__name__)
class WebDAVConnectionError(DvcException):
def __init__(self, host):
super().__init__(f"Unable to connect to WebDAV {host}.")
class WebDAVFileSystem(BaseFileSystem): # pylint:disable=abstract-method
# Use webdav scheme
class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
scheme = Schemes.WEBDAV
# URLInfo for Webdav ~ replaces webdav -> http
PATH_CLS = WebDAVURLInfo
# Traversable as walk_files is implemented
CAN_TRAVERSE = True
# Length of walk_files prefix
TRAVERSE_PREFIX_LEN = 2
# Implementation based on webdav3.client
REQUIRES = {"webdavclient3": "webdav3.client"}
# Chunk size for buffered upload/download with progress bar
CHUNK_SIZE = 2 ** 16
REQUIRES = {"webdav4": "webdav4"}
PARAM_CHECKSUM = "etag"
DETAIL_FIELDS = frozenset(("etag", "size"))
# Constructor
def __init__(self, **config):
# Call BaseFileSystem constructor
super().__init__(**config)
# Get username from configuration
self.user = config.get("user", None)
cert_path = config.get("cert_path", None)
key_path = config.get("key_path", None)
self.prefix = config.get("prefix", "")
self.fs_args.update(
{
"base_url": config["url"],
"cert": cert_path if not key_path else (cert_path, key_path),
"verify": config.get("ssl_verify", True),
"timeout": config.get("timeout", 30),
}
)
# Get password from configuration (might be None ~ not set)
self.password = config.get("password", None)
# Whether to ask for password if it is not set
self.ask_password = config.get("ask_password", False)
# Use token for webdav auth
self.token = config.get("token", None)
# Path to certificate
self.cert_path = config.get("cert_path", None)
# Path to private key
self.key_path = config.get("key_path", None)
# Connection timeout
self.timeout = config.get("timeout", 30)
# Get URL from configuration
self.url = config.get("url", None)
# If URL in config parse path_info
if self.url:
self.path_info = self.PATH_CLS(self.url)
# If username not specified try to use from URL
if self.user is None and self.path_info.user is not None:
self.user = self.path_info.user
# Construct hostname from path_info by stripping path
http_info = HTTPURLInfo(self.path_info.url)
self.hostname = http_info.replace(path="").url
# Webdav client
@wrap_prop(threading.Lock())
@cached_property
def _client(self):
from webdav3.client import Client
# Set password or ask for it
if self.ask_password and self.password is None and self.token is None:
self.password = ask_password(self.hostname, self.user)
# Setup webdav client options dictionary
options = {
"webdav_hostname": self.hostname,
"webdav_login": self.user,
"webdav_password": self.password,
"webdav_token": self.token,
"webdav_cert_path": self.cert_path,
"webdav_key_path": self.key_path,
"webdav_timeout": self.timeout,
"webdav_chunk_size": self.CHUNK_SIZE,
@staticmethod
def _get_kwargs_from_urls(urlpath):
path_info = WebDAVURLInfo(urlpath)
return {
"prefix": path_info.path.rstrip("/"),
"host": path_info.replace(path="").url,
"url": path_info.url.rstrip("/"),
"user": path_info.user,
}
client = Client(options)
def _prepare_credentials(self, **config):
user = config.get("user", None)
password = config.get("password", None)
# Check whether client options are valid
if not client.valid():
raise ConfigError(
f"Configuration for WebDAV {self.hostname} is invalid."
)
headers = {}
token = config.get("token")
auth = None
if token:
headers.update({"Authorization": f"Bearer {token}"})
elif user:
if not password and config.get("ask_password"):
password = ask_password(config["host"], user)
auth = (user, password)
# Check whether connection is valid (root should always exist)
if not client.check(self.path_info.path):
raise WebDAVConnectionError(self.hostname)
return {"headers": headers, "auth": auth}
return client
@wrap_prop(threading.Lock())
@cached_property
def fs(self):
from webdav4.fsspec import WebdavFileSystem
def open(self, path_info, mode="r", encoding=None, **kwargs):
from webdav3.exceptions import RemoteResourceNotFound
return WebdavFileSystem(**self.fs_args)
assert mode in {"r", "rt", "rb"}
def _upload_fobj(self, fobj, to_info, size: int = None):
rpath = self.translate_path_info(to_info)
self.makedirs(os.path.dirname(rpath))
# 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
)
fobj = io.BytesIO()
try:
self._client.download_from(buff=fobj, remote_path=path_info.path)
except RemoteResourceNotFound as exc:
raise FileNotFoundError from exc
fobj.seek(0)
if "mode" == "rb":
return fobj
return io.TextIOWrapper(fobj, encoding=encoding)
# Checks whether file/directory exists at remote
def exists(self, path_info) -> bool:
# Use webdav check to test for file existence
return self._client.check(path_info.path)
# Checks whether path points to directory
def isdir(self, path_info):
# Use webdav is_dir to test whether path points to a directory
return self._client.is_dir(path_info.path)
def walk_files(self, path_info, **kwargs):
if not self.exists(path_info):
return
for file in self.find(path_info):
yield path_info.replace(path=file)
def ls(self, path_info, detail=False):
for entry in self._client.list(path_info.path):
path = entry["path"]
if detail:
if entry["isdir"]:
yield {"type": "directory", "name": path}
else:
yield {
"type": "file",
"name": path,
"size": entry["size"],
"etag": entry["etag"],
}
else:
yield path
def find(self, path_info, detail=False):
dirs = deque([path_info.path])
while dirs:
for entry in self._client.list(dirs.pop(), get_info=True):
path = entry["path"]
if entry["isdir"]:
dirs.append(path)
continue
if detail:
yield {
"type": "file",
"name": path,
"size": entry["size"],
"etag": entry["etag"],
}
else:
yield path
# Removes file/directory
def remove(self, path_info):
# Use webdav client clean (DELETE) method to remove file/directory
self._client.clean(path_info.path)
# Creates directories
def makedirs(self, path_info):
# Terminate recursion
if path_info.path == self.path_info.path or self.exists(path_info):
return
path = self.translate_path_info(path_info)
return self.fs.makedirs(path, exist_ok=True)
# Recursively descent to root
self.makedirs(path_info.parent)
@lru_cache(512)
def translate_path_info(self, path):
if isinstance(path, self.PATH_CLS):
return path.path[len(self.prefix) :].lstrip("/")
return path
# Construct directory at current recursion depth
self._client.mkdir(path_info.path)
_with_bucket = translate_path_info
# Moves file/directory at remote
def move(self, from_info, to_info):
# Webdav client move
self._client.move(from_info.path, to_info.path)
def _strip_bucket(self, entry):
return entry
def _upload_fobj(self, fobj, to_info):
# In contrast to other upload_fobj implementations, this one does not
# exactly do a chunked-upload but rather put everything in one request.
self.makedirs(to_info.parent)
self._client.upload_to(buff=fobj, remote_path=to_info.path)
# Downloads file from remote to file
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
# Progress from HTTPFileSystem
with open(to_file, "wb") as fd:
with Tqdm.wrapattr(
fd,
"write",
total=None if no_progress_bar else self.getsize(from_info),
leave=False,
desc=from_info.url if name is None else name,
disable=no_progress_bar,
) as fd_wrapped:
# Download from WebDAV via buffer
self._client.download_from(
buff=fd_wrapped, remote_path=from_info.path
)
# Uploads file to remote
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
# First try to create parent directories
self.makedirs(to_info.parent)
file_size = os.path.getsize(from_file)
with open(from_file, "rb") as fd:
progress_context = (
nullcontext(fd)
if file_size == 0
else Tqdm.wrapattr(
fd,
"read",
total=None if no_progress_bar else file_size,
leave=False,
desc=to_info.url if name is None else name,
disable=no_progress_bar,
)
)
with progress_context as fd_wrapped:
self._client.upload_to(
buff=fd_wrapped, remote_path=to_info.path
)
def info(self, path_info):
info = self._client.info(path_info.path)
return {"size": int(info["size"]), "etag": info["etag"]}
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
scheme = Schemes.WEBDAVS
-7
View File
@@ -1,7 +0,0 @@
from dvc.scheme import Schemes
from .webdav import WebDAVFileSystem
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
scheme = Schemes.WEBDAVS
+14 -14
View File
@@ -4,7 +4,6 @@ import posixpath
import shutil
import threading
from contextlib import contextmanager
from urllib.parse import urlparse
from funcy import cached_property, wrap_prop
@@ -40,23 +39,24 @@ class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def __init__(self, **config):
super().__init__(**config)
self.path_info = None
url = config.get("url")
if not url:
return
self.path_info = self.PATH_CLS(url)
parsed = urlparse(url)
self.host = parsed.hostname
self.user = parsed.username or config.get("user")
self.port = parsed.port
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")
@staticmethod
def _get_kwargs_from_urls(urlpath):
from fsspec.implementations.webhdfs import WebHDFS
return (
WebHDFS._get_kwargs_from_urls( # pylint:disable=protected-access
urlpath
)
)
@wrap_prop(threading.Lock())
@cached_property
def hdfs_client(self):
@@ -141,7 +141,7 @@ class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
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):
def _upload_fobj(self, fobj, to_info, **kwargs):
with self.hdfs_client.write(to_info.path) as fdest:
shutil.copyfileobj(fobj, fdest)
+3
View File
@@ -21,6 +21,9 @@ class HashInfo:
def __str__(self):
return f"{self.name}: {self.value}"
def __hash__(self):
return hash((self.name, self.value))
@classmethod
def from_dict(cls, d):
_d = d.copy() if d else {}
+12 -2
View File
@@ -1,14 +1,24 @@
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import TYPE_CHECKING, Optional
from dvc.progress import Tqdm
from .tree import Tree
if TYPE_CHECKING:
from .db.base import ObjectDB
from .file import HashFile
logger = logging.getLogger(__name__)
def save(odb, obj, jobs=None, **kwargs):
def save(
odb: "ObjectDB",
obj: "HashFile",
jobs: Optional[int] = None,
**kwargs,
):
if isinstance(obj, Tree):
with ThreadPoolExecutor(max_workers=jobs) as executor:
for future in Tqdm(
@@ -18,7 +28,7 @@ def save(odb, obj, jobs=None, **kwargs):
entry.path_info,
entry.fs,
entry.hash_info,
**kwargs
**kwargs,
)
for _, entry in obj
),
+7 -105
View File
@@ -1,24 +1,22 @@
from collections import defaultdict
from dvc.scheme import Schemes
def get_odb(fs, **config):
def get_odb(fs, path_info, **config):
from .base import ObjectDB
from .gdrive import GDriveObjectDB
from .local import LocalObjectDB
from .ssh import SSHObjectDB
if fs.scheme == Schemes.LOCAL:
return LocalObjectDB(fs, **config)
return LocalObjectDB(fs, path_info, **config)
if fs.scheme == Schemes.SSH:
return SSHObjectDB(fs, **config)
return SSHObjectDB(fs, path_info, **config)
if fs.scheme == Schemes.GDRIVE:
return GDriveObjectDB(fs, **config)
return GDriveObjectDB(fs, path_info, **config)
return ObjectDB(fs, **config)
return ObjectDB(fs, path_info, **config)
def _get_odb(repo, settings):
@@ -27,8 +25,8 @@ def _get_odb(repo, settings):
if not settings:
return None
cls, config = get_cloud_fs(repo, **settings)
return get_odb(cls(**config), state=repo.state, **config)
cls, config, path_info = get_cloud_fs(repo, **settings)
return get_odb(cls(**config), path_info, state=repo.state, **config)
class ODBManager:
@@ -80,99 +78,3 @@ class ODBManager:
def by_scheme(self):
self._init_odb(self.CLOUD_SCHEMES)
yield from self._odb.items()
class NamedCacheItem:
def __init__(self):
self.names = set()
self.children = defaultdict(NamedCacheItem)
def __eq__(self, other):
return self.names == other.names and self.children == other.children
def child_keys(self):
for key, child in self.children.items():
yield key
yield from child.child_keys()
def child_names(self):
for key, child in self.children.items():
yield key, child.names
yield from child.child_names()
def add(self, checksum, item):
self.children[checksum].update(item)
def update(self, item, suffix=""):
if suffix:
self.names.update(n + suffix for n in item.names)
else:
self.names.update(item.names)
for checksum, child_item in item.children.items():
self.children[checksum].update(child_item)
class NamedCache:
# pylint: disable=protected-access
def __init__(self):
self._items = defaultdict(lambda: defaultdict(NamedCacheItem))
self.external = defaultdict(set)
@classmethod
def make(cls, scheme, checksum, name):
cache = cls()
cache.add(scheme, checksum, name)
return cache
def __getitem__(self, key):
return self._items[key]
def add(self, scheme, checksum, name):
"""Add a mapped name for the specified checksum."""
self._items[scheme][checksum].names.add(name)
def add_child_cache(self, checksum, cache, suffix=""):
"""Add/update child cache for the specified checksum."""
for scheme, src in cache._items.items():
dst = self._items[scheme][checksum].children
for child_checksum, item in src.items():
dst[child_checksum].update(item, suffix=suffix)
for repo_pair, files in cache.external.items():
self.external[repo_pair].update(files)
def add_external(self, url, rev, path):
self.external[url, rev].add(path)
def update(self, cache, suffix=""):
for scheme, src in cache._items.items():
dst = self._items[scheme]
for checksum, item in src.items():
dst[checksum].update(item, suffix=suffix)
for repo_pair, files in cache.external.items():
self.external[repo_pair].update(files)
def scheme_keys(self, scheme):
"""Iterate over a flat list of all keys for the specified scheme,
including children.
"""
for key, item in self._items[scheme].items():
yield key
yield from item.child_keys()
def scheme_names(self, scheme):
"""Iterate over a flat list of checksum, names items for the specified
scheme, including children.
"""
for key, item in self._items[scheme].items():
yield key, item.names
yield from item.child_names()
def dir_keys(self, scheme):
return (
key for key, item in self._items[scheme].items() if item.children
)
def child_keys(self, scheme, checksum):
return self._items[scheme][checksum].child_keys()
+43 -9
View File
@@ -7,6 +7,7 @@ from typing import Optional
from dvc.objects.errors import ObjectFormatError
from dvc.objects.file import HashFile
from dvc.objects.tree import Tree
from dvc.progress import Tqdm
logger = logging.getLogger(__name__)
@@ -18,15 +19,33 @@ class ObjectDB:
DEFAULT_CACHE_TYPES = ["copy"]
CACHE_MODE: Optional[int] = None
def __init__(self, fs, **config):
def __init__(self, fs, path_info, **config):
from dvc.state import StateNoop
self.fs = fs
self.path_info = path_info
self.state = config.get("state", StateNoop())
self.verify = config.get("verify", self.DEFAULT_VERIFY)
self.cache_types = config.get("type") or copy(self.DEFAULT_CACHE_TYPES)
self.cache_type_confirmed = False
self.slow_link_warning = config.get("slow_link_warning", True)
self.tmp_dir = config.get("tmp_dir")
@property
def config(self):
return {
"state": self.state,
"verify": self.verify,
"type": self.cache_types,
"slow_link_warning": self.slow_link_warning,
"tmp_dir": self.tmp_dir,
}
def __eq__(self, other):
return self.fs == other.fs and self.path_info == other.path_info
def __hash__(self):
return hash((self.fs.scheme, self.path_info))
def move(self, from_info, to_info):
self.fs.move(from_info, to_info)
@@ -87,7 +106,7 @@ class ObjectDB:
callback(1)
def hash_to_path_info(self, hash_):
return self.fs.path_info / hash_[0:2] / hash_[2:]
return self.path_info / hash_[0:2] / hash_[2:]
# Override to return path as a string instead of PathInfo for clouds
# which support string paths (see local)
@@ -143,12 +162,12 @@ class ObjectDB:
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
if len(prefix) > 2:
path_info = self.fs.path_info / prefix[:2] / prefix[2:]
path_info = self.path_info / prefix[:2] / prefix[2:]
else:
path_info = self.fs.path_info / prefix[:2]
path_info = self.path_info / prefix[:2]
prefix = True
else:
path_info = self.fs.path_info
path_info = self.path_info
prefix = False
if progress_callback:
for file_info in self.fs.walk_files(path_info, prefix=prefix):
@@ -316,14 +335,22 @@ class ObjectDB:
pass
def gc(self, used, jobs=None):
used_hashes = set()
for obj in used:
used_hashes.add(obj.hash_info.value)
if isinstance(obj, Tree):
used_hashes.update(
entry_obj.hash_info.value for _, entry_obj in obj
)
removed = False
# hashes must be sorted to ensure we always remove .dir files first
for hash_ in sorted(
self.all(jobs, str(self.fs.path_info)),
self.all(jobs, str(self.path_info)),
key=self.fs.is_dir_hash,
reverse=True,
):
if hash_ in used:
if hash_ in used_hashes:
continue
path_info = self.hash_to_path_info(hash_)
if self.fs.is_dir_hash(hash_):
@@ -399,8 +426,15 @@ class ObjectDB:
# hashes_exist() (see ssh, local)
assert self.fs.TRAVERSE_PREFIX_LEN >= 2
# During the tests, for ensuring that the traverse behavior
# is working we turn on this option. It will ensure the
# list_hashes_traverse() is called.
always_traverse = getattr(self.fs, "_ALWAYS_TRAVERSE", False)
hashes = set(hashes)
if len(hashes) == 1 or not self.fs.CAN_TRAVERSE:
if (
len(hashes) == 1 or not self.fs.CAN_TRAVERSE
) and not always_traverse:
remote_hashes = self.list_hashes_exists(hashes, jobs, name)
return remote_hashes
@@ -418,7 +452,7 @@ class ObjectDB:
)
else:
traverse_weight = traverse_pages
if len(hashes) < traverse_weight:
if len(hashes) < traverse_weight and not always_traverse:
logger.debug(
"Large remote ('{}' hashes < '{}' traverse weight), "
"using object_exists for remaining hashes".format(
+24
View File
@@ -0,0 +1,24 @@
import logging
from .base import ObjectDB
logger = logging.getLogger(__name__)
class GitObjectDB(ObjectDB):
"""Dummy read-only ODB for uncached objects in external Git repos."""
def __init__(self, fs, path_info, **config):
from dvc.fs.repo import RepoFileSystem
assert isinstance(fs, RepoFileSystem)
super().__init__(fs, path_info)
def get(self, hash_info):
raise NotImplementedError
def add(self, path_info, fs, hash_info, move=True, **kwargs):
raise NotImplementedError
def gc(self, used, jobs=None):
raise NotImplementedError
+8 -8
View File
@@ -22,9 +22,9 @@ class LocalObjectDB(ObjectDB):
CACHE_MODE = 0o444
UNPACKED_DIR_SUFFIX = ".unpacked"
def __init__(self, fs, **config):
super().__init__(fs, **config)
self.cache_dir = config.get("url")
def __init__(self, fs, path_info, **config):
super().__init__(fs, path_info, **config)
self.cache_dir = path_info.fspath
shared = config.get("shared")
if shared:
@@ -36,11 +36,11 @@ class LocalObjectDB(ObjectDB):
@property
def cache_dir(self):
return self.fs.path_info.fspath if self.fs.path_info else None
return self.path_info.fspath if self.path_info else None
@cache_dir.setter
def cache_dir(self, value):
self.fs.path_info = PathInfo(value) if value else None
self.path_info = PathInfo(value) if value else None
@cached_property
def cache_path(self):
@@ -81,13 +81,13 @@ class LocalObjectDB(ObjectDB):
return ret
def _list_paths(self, prefix=None, progress_callback=None):
assert self.fs.path_info is not None
assert self.path_info is not None
if prefix:
path_info = self.fs.path_info / prefix[:2]
path_info = self.path_info / prefix[:2]
if not self.fs.exists(path_info):
return
else:
path_info = self.fs.path_info
path_info = self.path_info
# NOTE: use utils.fs walk_files since fs.walk_files will not follow
# symlinks
if progress_callback:
+3 -3
View File
@@ -75,10 +75,10 @@ class SSHObjectDB(ObjectDB):
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
root = posixpath.join(self.fs.path_info.path, prefix[:2])
root = posixpath.join(self.path_info.path, prefix[:2])
else:
root = self.fs.path_info.path
with self.fs.ssh(self.fs.path_info) as ssh:
root = self.path_info.path
with self.fs.ssh(self.path_info) as ssh:
if prefix and not ssh.exists(root):
return
# If we simply return an iterator then with above closes instantly
+23 -1
View File
@@ -1,17 +1,30 @@
import errno
import logging
import os
from typing import TYPE_CHECKING, Optional
from .errors import ObjectFormatError
if TYPE_CHECKING:
from dvc.fs.base import BaseFileSystem
from dvc.hash_info import HashInfo
from dvc.types import DvcPath
logger = logging.getLogger(__name__)
class HashFile:
def __init__(self, path_info, fs, hash_info):
def __init__(
self,
path_info: Optional["DvcPath"],
fs: Optional["BaseFileSystem"],
hash_info: "HashInfo",
name: Optional[str] = None,
):
self.path_info = path_info
self.fs = fs
self.hash_info = hash_info
self.name = name
@property
def size(self):
@@ -37,6 +50,15 @@ class HashFile:
and self.hash_info == other.hash_info
)
def __hash__(self):
return hash(
(
self.hash_info,
self.path_info,
self.fs.scheme if self.fs else None,
)
)
def check(self, odb, check_hash=True):
from .stage import get_file_hash
+2 -2
View File
@@ -15,11 +15,11 @@ def _upload_file(path_info, fs, odb):
from dvc.utils import tmp_fname
from dvc.utils.stream import HashedStreamReader
tmp_info = odb.fs.path_info / tmp_fname()
tmp_info = odb.path_info / tmp_fname()
with fs.open(path_info, mode="rb", chunk_size=fs.CHUNK_SIZE) as stream:
stream = HashedStreamReader(stream)
odb.fs.upload_fobj(
stream, tmp_info, desc=path_info.name, total=fs.getsize(path_info)
stream, tmp_info, desc=path_info.name, size=fs.getsize(path_info)
)
obj = HashFile(tmp_info, odb.fs, stream.hash_info)
+30 -5
View File
@@ -1,6 +1,7 @@
import json
import logging
import posixpath
from typing import TYPE_CHECKING, Optional, Tuple
from funcy import cached_property
@@ -8,6 +9,9 @@ from .errors import ObjectFormatError
from .file import HashFile
from .stage import get_file_hash
if TYPE_CHECKING:
from .db.base import ObjectDB
logger = logging.getLogger(__name__)
@@ -115,24 +119,45 @@ class Tree(HashFile):
tree = cls.from_list(raw)
tree.path_info = obj.path_info
tree.fs = obj.fs
for _, entry_obj in tree:
entry_obj.fs = obj.fs
tree.hash_info = hash_info
return tree
def filter(self, odb, prefix):
def filter(
self, odb: "ObjectDB", prefix: Tuple[str], copy: bool = False
) -> Optional[HashFile]:
"""Return filter object(s) for this tree.
If copy is True, returned object will be a Tree containing
filtered entries, but with hash_info copied from the original tree.
If copy is False, returned object will be a raw HashFile or Tree with
newly computed hash_info for the filtered object.
"""
obj = self._dict.get(prefix)
if obj:
if copy:
tree = Tree(self.path_info, self.fs, self.hash_info)
tree.add(prefix, obj)
return tree
return obj
depth = len(prefix)
tree = Tree(None, None, None)
if copy:
tree = Tree(self.path_info, self.fs, self.hash_info)
depth = 0
else:
tree = Tree(None, None, None)
depth = len(prefix)
try:
for key, obj in self.trie.items(prefix):
tree.add(key[depth:], obj)
except KeyError:
return None
tree.digest()
odb.add(tree.path_info, tree.fs, tree.hash_info)
if not copy:
tree.digest()
odb.add(tree.path_info, tree.fs, tree.hash_info)
return tree
+49 -70
View File
@@ -2,7 +2,7 @@ import logging
import os
from collections import defaultdict
from copy import copy
from typing import Type
from typing import TYPE_CHECKING, Dict, Optional, Set, Type
from urllib.parse import urlparse
from funcy import collecting, project
@@ -24,14 +24,18 @@ from .fs.local import LocalFileSystem
from .fs.s3 import S3FileSystem
from .hash_info import HashInfo
from .istextfile import istextfile
from .objects import Tree
from .objects import save as osave
from .objects.db import NamedCache
from .objects.errors import ObjectFormatError
from .objects.stage import stage as ostage
from .scheme import Schemes
from .utils import relpath
from .utils.fs import path_isin
if TYPE_CHECKING:
from .objects.db.base import ObjectDB
from .objects.file import HashFile
logger = logging.getLogger(__name__)
@@ -279,9 +283,8 @@ class Output:
):
self.repo = stage.repo if stage else None
fs_cls, fs_config = get_cloud_fs(self.repo, url=path)
fs_cls, fs_config, path_info = get_cloud_fs(self.repo, url=path)
self.fs = fs_cls(**fs_config)
path_info = self.fs.path_info
if (
self.fs.scheme == "local"
@@ -320,6 +323,8 @@ class Output:
self.obj = None
self.isexec = False if self.IS_DEPENDENCY else isexec
self.def_remote = None
def _parse_path(self, fs, path_info):
if fs.scheme != "local":
return path_info
@@ -648,7 +653,7 @@ class Output:
def download(self, to, jobs=None):
self.fs.download(self.path_info, to.path_info, jobs=jobs)
def get_obj(self, filter_info=None):
def get_obj(self, filter_info=None, **kwargs):
if self.obj:
obj = self.obj
elif self.hash_info:
@@ -661,7 +666,7 @@ class Output:
if filter_info and filter_info != self.path_info:
prefix = filter_info.relative_to(self.path_info).parts
obj = obj.filter(self.odb, prefix)
obj = obj.filter(self.odb, prefix, **kwargs)
return obj
@@ -741,9 +746,8 @@ class Output:
if odb is None:
odb = self.odb
cls, config = get_cloud_fs(self.repo, url=source)
cls, config, from_info = get_cloud_fs(self.repo, url=source)
from_fs = cls(**config)
from_info = from_fs.path_info
# When running import-url --to-remote / add --to-remote/-o ... we
# assume that it is unlikely that the odb will contain majority of the
@@ -788,18 +792,14 @@ class Output:
self.odb.unprotect(self.path_info)
def get_dir_cache(self, **kwargs):
if not self.is_dir_checksum:
raise DvcException("cannot get dir cache for file checksum")
obj = self.odb.get(self.hash_info)
try:
objects.check(self.odb, self.odb.get(self.hash_info))
objects.check(self.odb, obj)
except (FileNotFoundError, ObjectFormatError):
self.repo.cloud.pull(
NamedCache.make("local", self.hash_info.value, str(self)),
show_checksums=False,
**kwargs,
)
self.repo.cloud.pull([obj], show_checksums=False, **kwargs)
try:
self.obj = objects.load(self.odb, self.hash_info)
@@ -810,27 +810,8 @@ class Output:
def collect_used_dir_cache(
self, remote=None, force=False, jobs=None, filter_info=None
):
"""Get a list of `info`s related to the given directory.
- Pull the directory entry from the remote cache if it was changed.
Example:
Given the following commands:
$ echo "foo" > directory/foo
$ echo "bar" > directory/bar
$ dvc add directory
It will return a NamedCache like:
nc = NamedCache()
nc.add(self.scheme, 'c157a79031e1', 'directory/foo')
nc.add(self.scheme, 'd3b07384d113', 'directory/bar')
"""
cache = NamedCache()
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
"""Fetch dir cache and return used objects for this out."""
try:
self.get_dir_cache(jobs=jobs, remote=remote)
@@ -850,38 +831,22 @@ class Output:
"unable to fully collect used cache"
" without cache for directory '{}'".format(self)
)
return cache
return {}
path = str(self.path_info)
filter_path = str(filter_info) if filter_info else None
for entry_key, entry_obj in self.obj:
entry_path = os.path.join(path, *entry_key)
if (
not filter_path
or entry_path == filter_path
or entry_path.startswith(filter_path + os.sep)
):
cache.add(self.scheme, entry_obj.hash_info.value, entry_path)
obj = self.get_obj(filter_info=filter_info, copy=True)
self._set_obj_names(obj)
return {None: {obj}}
return cache
def get_used_cache(self, **kwargs):
"""Get a dumpd of the given `out`, with an entry including the branch.
The `used_cache` of an output is no more than its `info`.
In case that the given output is a directory, it will also
include the `info` of its files.
"""
def get_used_objs(
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
"""Return filtered set of used objects for this out."""
if not self.use_cache:
return NamedCache()
return {}
if self.stage.is_repo_import:
cache = NamedCache()
(dep,) = self.stage.deps
cache.external[dep.repo_pair].add(dep.def_path)
return cache
return self.get_used_external(**kwargs)
if not self.hash_info:
msg = (
@@ -900,18 +865,32 @@ class Output:
)
)
logger.warning(msg)
return NamedCache()
return {}
ret = NamedCache.make(self.scheme, self.hash_info.value, str(self))
if self.is_dir_checksum:
return self.collect_used_dir_cache(**kwargs)
if not self.is_dir_checksum:
return ret
obj = self.get_obj(filter_info=kwargs.get("filter_info"))
if not obj:
obj = self.odb.get(self.hash_info)
self._set_obj_names(obj)
ret.add_child_cache(
self.hash_info.value, self.collect_used_dir_cache(**kwargs)
)
return {None: {obj}}
return ret
def _set_obj_names(self, obj):
obj.name = str(self)
if isinstance(obj, Tree):
for key, entry_obj in obj:
entry_obj.name = os.path.join(str(self), *key)
def get_used_external(
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
if not self.use_cache or not self.stage.is_repo_import:
return {}
(dep,) = self.stage.deps
return dep.get_used_objs()
def _validate_output_path(self, path, stage=None):
from dvc.dvcfile import is_valid_filename
+2 -1
View File
@@ -6,6 +6,7 @@ from threading import RLock
from tqdm import tqdm
from dvc.env import DVC_IGNORE_ISATTY
from dvc.utils import env2bool
logger = logging.getLogger(__name__)
@@ -77,7 +78,7 @@ class Tqdm(tqdm):
# auto-disable based on TTY
if (
not disable
and not env2bool("DVC_IGNORE_ISATTY")
and not env2bool(DVC_IGNORE_ISATTY)
and hasattr(file, "isatty")
):
disable = not file.isatty()
+3 -3
View File
@@ -4,8 +4,8 @@ from .local import LocalRemote
def get_remote(repo, **kwargs):
cls, config = get_cloud_fs(repo, **kwargs)
cls, config, path_info = get_cloud_fs(repo, **kwargs)
fs = cls(**config)
if fs.scheme == "local":
return LocalRemote(fs, repo.tmp_dir, **config)
return Remote(fs, repo.tmp_dir, **config)
return LocalRemote(fs, path_info, repo.tmp_dir, **config)
return Remote(fs, path_info, repo.tmp_dir, **config)
+92 -49
View File
@@ -8,9 +8,10 @@ from functools import partial, wraps
from dvc.exceptions import DownloadError, UploadError
from dvc.hash_info import HashInfo
from dvc.objects.tree import Tree
from ..progress import Tqdm
from .index import RemoteIndex, RemoteIndexNoop
from .index import RemoteIndex
logger = logging.getLogger(__name__)
@@ -67,25 +68,39 @@ class Remote:
INDEX_CLS = RemoteIndex
def __init__(self, fs, tmp_dir, **config):
def __init__(self, fs, path_info, tmp_dir, **config):
from dvc.objects.db import get_odb
self.fs = fs
self.odb = get_odb(self.fs, **config)
config["tmp_dir"] = tmp_dir
self.odb = get_odb(self.fs, path_info, **config)
url = config.get("url")
if url:
index_name = hashlib.sha256(url.encode("utf-8")).hexdigest()
self.index = self.INDEX_CLS(
tmp_dir, index_name, dir_suffix=self.fs.CHECKSUM_DIR_SUFFIX
)
else:
self.index = RemoteIndexNoop()
url = getattr(path_info, "fspath", path_info.url)
index_name = hashlib.sha256(url.encode("utf-8")).hexdigest()
self.index = self.INDEX_CLS(
tmp_dir, index_name, dir_suffix=self.fs.CHECKSUM_DIR_SUFFIX
)
def __repr__(self):
return "{class_name}: '{path_info}'".format(
class_name=type(self).__name__,
path_info=self.fs.path_info or "No path",
path_info=self.odb.path_info or "No path",
)
def __eq__(self, other):
return self.odb == other.odb
def __hash__(self):
return hash(self.odb)
@classmethod
def from_odb(cls, odb):
config = odb.config
return cls(
odb.fs,
odb.path_info,
config.pop("tmp_dir"),
**config,
)
@index_locked
@@ -101,7 +116,7 @@ class Remote:
def status(
self,
cache,
named_cache,
objs,
jobs=None,
show_checksums=False,
download=False,
@@ -110,7 +125,7 @@ class Remote:
# Return flattened dict containing all status info
dir_status, file_status, _ = self._status(
cache,
named_cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=download,
@@ -129,10 +144,10 @@ class Remote:
return indexed_hashes + self.odb.hashes_exist(list(hashes), **kwargs)
def _status(
def _status( # pylint: disable=unused-argument
self,
cache,
named_cache,
objs,
jobs=None,
show_checksums=False,
download=False,
@@ -145,8 +160,18 @@ class Remote:
{dir_hash: set(file_hash, ...)} which can be used to map
a .dir file to its file contents.
"""
logger.debug(f"Preparing to collect status from {self.fs.path_info}")
md5s = set(named_cache.scheme_keys(cache.fs.scheme))
logger.debug(f"Preparing to collect status from {self.odb.path_info}")
md5s = set()
dir_objs = {}
for obj in objs:
if obj.fs.scheme != cache.fs.scheme:
continue
md5s.add(obj.hash_info.value)
if isinstance(obj, Tree):
dir_objs[obj.hash_info.value] = obj
for _, entry_obj in obj:
md5s.add(entry_obj.hash_info.value)
logger.debug("Collecting information from local cache...")
local_exists = frozenset(
@@ -162,21 +187,18 @@ class Remote:
else:
logger.debug("Collecting information from remote cache...")
remote_exists = set()
dir_md5s = set(named_cache.dir_keys(cache.fs.scheme))
if dir_md5s:
remote_exists.update(
self._indexed_dir_hashes(cache, named_cache, dir_md5s)
)
if dir_objs:
remote_exists.update(self._indexed_dir_hashes(dir_objs))
md5s.difference_update(remote_exists)
if md5s:
remote_exists.update(
self.hashes_exist(
md5s, jobs=jobs, name=str(self.fs.path_info)
md5s, jobs=jobs, name=str(self.odb.path_info)
)
)
return self._make_status(
cache,
named_cache,
objs,
show_checksums,
local_exists,
remote_exists,
@@ -186,29 +208,36 @@ class Remote:
def _make_status(
self,
cache,
named_cache,
objs,
show_checksums,
local_exists,
remote_exists,
log_missing,
):
def make_names(hash_, names):
return {"name": hash_ if show_checksums else " ".join(names)}
def make_names(obj):
if show_checksums or obj.name is None:
name = obj.hash_info.value
else:
name = obj.name
return {"name": name}
dir_status = {}
file_status = {}
dir_contents = {}
for hash_, item in named_cache[cache.fs.scheme].items():
if item.children:
dir_status[hash_] = make_names(hash_, item.names)
for obj in objs:
if obj.fs.scheme != cache.fs.scheme:
continue
hash_ = obj.hash_info.value
if isinstance(obj, Tree):
dir_status[hash_] = make_names(obj)
dir_contents[hash_] = set()
for child_hash, child in item.children.items():
file_status[child_hash] = make_names(
child_hash, child.names
)
for _, child_obj in obj:
child_hash = child_obj.hash_info.value
file_status[child_hash] = make_names(child_obj)
dir_contents[hash_].add(child_hash)
else:
file_status[hash_] = make_names(hash_, item.names)
file_status[hash_] = make_names(obj)
self._fill_statuses(dir_status, local_exists, remote_exists)
self._fill_statuses(file_status, local_exists, remote_exists)
@@ -218,9 +247,10 @@ class Remote:
return dir_status, file_status, dir_contents
def _indexed_dir_hashes(self, cache, named_cache, dir_md5s):
def _indexed_dir_hashes(self, dir_objs):
# Validate our index by verifying all indexed .dir hashes
# still exist on the remote
dir_md5s = set(dir_objs.keys())
indexed_dirs = set(self.index.dir_hashes())
indexed_dir_exists = set()
if indexed_dirs:
@@ -242,9 +272,9 @@ class Remote:
# If .dir hash exists on the remote, assume directory contents
# still exists on the remote
for dir_hash in dir_exists:
file_hashes = list(
named_cache.child_keys(cache.fs.scheme, dir_hash)
)
file_hashes = [
obj.hash_info.value for _, obj in dir_objs[dir_hash]
]
if dir_hash not in self.index:
logger.debug(
"Indexing new .dir '{}' with '{}' nested files".format(
@@ -294,7 +324,7 @@ class Remote:
def _process(
self,
cache,
named_cache,
objs,
jobs=None,
show_checksums=False,
download=False,
@@ -302,7 +332,7 @@ class Remote:
logger.debug(
"Preparing to {} '{}'".format(
"download data from" if download else "upload data to",
self.fs.path_info,
self.odb.path_info,
)
)
@@ -320,7 +350,7 @@ class Remote:
dir_status, file_status, dir_contents = self._status(
cache,
named_cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=download,
@@ -460,17 +490,20 @@ class Remote:
self.index.update([dir_hash], file_hashes)
@index_locked
def push(self, cache, named_cache, jobs=None, show_checksums=False):
def push(self, cache, objs, jobs=None, show_checksums=False):
ret = self._process(
cache,
named_cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=False,
)
if self.fs.scheme == "local":
for checksum in named_cache.scheme_keys("local"):
for obj in objs:
if obj.fs.scheme != "local":
continue
checksum = obj.hash_info.value
cache_file = self.odb.hash_to_path_info(checksum)
if self.fs.exists(cache_file):
hash_info = HashInfo(self.fs.PARAM_CHECKSUM, checksum)
@@ -480,17 +513,19 @@ class Remote:
return ret
@index_locked
def pull(self, cache, named_cache, jobs=None, show_checksums=False):
def pull(self, cache, objs, jobs=None, show_checksums=False):
ret = self._process(
cache,
named_cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=True,
)
if not self.odb.verify:
for checksum in named_cache.scheme_keys("local"):
def save_state(obj):
checksum = obj.hash_info.value
cache_file = cache.hash_to_path_info(checksum)
if cache.fs.exists(cache_file):
# We can safely save here, as existing corrupted files
@@ -501,6 +536,14 @@ class Remote:
cache.state.save(cache_file, cache.fs, hash_info)
cache.protect(cache_file)
for obj in objs:
if obj.fs.scheme != "local":
continue
save_state(obj)
if isinstance(obj, Tree):
for _, entry_obj in obj:
save_state(entry_obj)
return ret
@staticmethod
+25 -14
View File
@@ -1,5 +1,6 @@
import logging
import os
from collections import defaultdict
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING, Callable, Optional
@@ -340,7 +341,7 @@ class Repo:
existing_stages = self.stages if old_stages is None else old_stages
build_graph(existing_stages + new_stages)
def used_cache(
def used_objs(
self,
targets=None,
all_branches=False,
@@ -366,13 +367,22 @@ class Repo:
the scope.
Returns:
A dictionary with Schemes (representing output's location) mapped
to items containing the output's `dumpd` names and the output's
children (if the given output is a directory).
A dict mapping (remote) ODB instances to sets of objects that
belong to each ODB. If the ODB instance is None, the objects
are naive and do not belong to a specific remote ODB.
"""
from dvc.objects.db import NamedCache
used = defaultdict(set)
cache = NamedCache()
def _add_suffix(objs, suffix):
from dvc.objects.tree import Tree
for obj in objs:
if obj.name is not None:
obj.name += suffix
if isinstance(obj, Tree):
for _, entry_obj in obj:
if entry_obj.name is not None:
entry_obj.name += suffix
for branch in self.brancher(
revs=revs,
@@ -390,23 +400,24 @@ class Repo:
for target in targets
)
suffix = f"({branch})" if branch else ""
for stage, filter_info in pairs:
used_cache = stage.get_used_cache(
for odb, objs in stage.get_used_objs(
remote=remote,
force=force,
jobs=jobs,
filter_info=filter_info,
)
cache.update(used_cache, suffix=suffix)
).items():
if branch:
_add_suffix(objs, f" ({branch})")
used[odb].update(objs)
if used_run_cache:
used_cache = self.stage_cache.get_used_cache(
for odb, objs in self.stage_cache.get_used_objs(
used_run_cache, remote=remote, force=force, jobs=jobs
)
cache.update(used_cache)
).items():
used[odb].update(objs)
return cache
return used
@cached_property
def outs_trie(self):
+74 -44
View File
@@ -1,9 +1,9 @@
import logging
import os
from dvc.config import NoRemoteError
from dvc.exceptions import DownloadError, NoOutputOrStageError
from dvc.exceptions import DownloadError
from ..scheme import Schemes
from . import locked
logger = logging.getLogger(__name__)
@@ -36,11 +36,12 @@ def fetch(
config.NoRemoteError: thrown when downloading only local files and no
remote is configured
"""
from dvc.objects.db.git import GitObjectDB
if isinstance(targets, str):
targets = [targets]
used = self.used_cache(
used = self.used_objs(
targets,
all_branches=all_branches,
all_tags=all_tags,
@@ -59,17 +60,39 @@ def fetch(
try:
if run_cache:
self.stage_cache.pull(remote)
downloaded += self.cloud.pull(
used, jobs, remote=remote, show_checksums=show_checksums
)
except NoRemoteError:
if not used.external and used["local"]:
raise
except DownloadError as exc:
failed += exc.amount
for (repo_url, repo_rev), files in used.external.items():
d, f = _fetch_external(self, repo_url, repo_rev, files, jobs)
external = set()
for odb, objs in used.items():
if odb is None:
# objs contains naive objects to be pulled from specified remote
d, f = _fetch_naive_objs(
self,
objs,
jobs=jobs,
remote=remote,
show_checksums=show_checksums,
)
downloaded += d
failed += f
elif isinstance(odb, GitObjectDB):
# objs contains staged import objects which should be saved
# last (after all other objects have been pulled)
external.update(objs)
else:
d, f = fetch_from_odb(
self,
odb,
objs,
jobs=jobs,
show_checksums=show_checksums,
)
downloaded += d
failed += f
if external:
d, f = _fetch_external(self, external, jobs=jobs)
downloaded += d
failed += f
@@ -79,44 +102,51 @@ def fetch(
return downloaded
def _fetch_external(self, repo_url, repo_rev, files, jobs):
from dvc.external_repo import external_repo
from dvc.objects import save
from dvc.objects.stage import stage
from dvc.path_info import PathInfo
from dvc.scm.base import CloneError
def _fetch_naive_objs(repo, objs, **kwargs):
# objs contains naive objects to be pulled from specified remote
downloaded = 0
failed = 0
try:
downloaded += repo.cloud.pull(objs, **kwargs)
except NoRemoteError:
if any(obj.fs.scheme == Schemes.LOCAL for obj in objs):
raise
except DownloadError as exc:
failed += exc.amount
return downloaded, failed
def fetch_from_odb(repo, odb, objs, **kwargs):
from dvc.remote.base import Remote
downloaded = 0
failed = 0
remote = Remote.from_odb(odb)
try:
downloaded += remote.pull(
repo.odb.local,
objs,
**kwargs,
)
except DownloadError as exc:
failed += exc.amount
return downloaded, failed
def _fetch_external(repo, objs, **kwargs):
from dvc.objects import save
from dvc.objects.errors import ObjectError
results = []
failed = 0
def cb(result):
def callback(result):
results.append(result)
odb = self.odb.local
try:
with external_repo(
repo_url, repo_rev, cache_dir=odb.cache_dir
) as repo:
root = PathInfo(repo.root_dir)
for path in files:
path_info = root / path
try:
used = repo.used_cache(
[os.fspath(path_info)],
force=True,
jobs=jobs,
recursive=True,
)
cb(repo.cloud.pull(used, jobs))
except (NoOutputOrStageError, NoRemoteError):
pass
obj = stage(odb, path_info, repo.repo_fs, "md5", jobs=jobs)
save(odb, obj, jobs=jobs, download_callback=cb)
except CloneError:
failed += 1
logger.exception(
"failed to fetch data for '{}'".format(", ".join(files))
)
for obj in objs:
try:
save(repo.odb.local, obj, download_callback=callback, **kwargs)
except ObjectError:
failed += 1
return sum(results), failed
+24 -16
View File
@@ -1,11 +1,14 @@
import logging
from typing import TYPE_CHECKING, Set
from dvc.exceptions import InvalidArgumentError
from dvc.objects.db import NamedCache
from ..scheme import Schemes
from . import locked
if TYPE_CHECKING:
from dvc.objects.file import HashFile
logger = logging.getLogger(__name__)
@@ -52,30 +55,32 @@ def gc(
repos = []
all_repos = [Repo(path) for path in repos]
used_objs: Set["HashFile"] = set()
with ExitStack() as stack:
for repo in all_repos:
stack.enter_context(repo.lock)
used = NamedCache()
for repo in all_repos + [self]:
used.update(
repo.used_cache(
all_branches=all_branches,
with_deps=with_deps,
all_tags=all_tags,
all_commits=all_commits,
all_experiments=all_experiments,
remote=remote,
force=force,
jobs=jobs,
)
)
for objs in repo.used_objs(
all_branches=all_branches,
with_deps=with_deps,
all_tags=all_tags,
all_commits=all_commits,
all_experiments=all_experiments,
remote=remote,
force=force,
jobs=jobs,
).values():
used_objs.update(objs)
for scheme, odb in self.odb.by_scheme():
if not odb:
continue
removed = odb.gc(set(used.scheme_keys(scheme)), jobs=jobs)
removed = odb.gc(
{obj for obj in used_objs if obj.fs.scheme == scheme},
jobs=jobs,
)
if not removed:
logger.info(f"No unused '{scheme}' cache to remove.")
@@ -83,6 +88,9 @@ def gc(
return
remote = self.cloud.get_remote(remote, "gc -c")
removed = remote.gc(set(used.scheme_keys(Schemes.LOCAL)), jobs=jobs)
removed = remote.gc(
{obj for obj in used_objs if obj.fs.scheme == Schemes.LOCAL},
jobs=jobs,
)
if not removed:
logger.info("No unused cache to remove from remote.")
+1 -1
View File
@@ -28,7 +28,7 @@ def ls(url, path=None, rev=None, recursive=None, dvc_only=False):
"""
from . import Repo
with Repo.open(url, rev, subrepos=True, uninitialized=True) as repo:
with Repo.open(url, rev=rev, subrepos=True, uninitialized=True) as repo:
path_info = PathInfo(repo.root_dir)
if path:
path_info /= path
+5 -1
View File
@@ -62,7 +62,11 @@ def move(self, from_path, to_path):
)
to_path = os.path.relpath(to_path, new_wdir)
new_stage = self.stage.create(
single_stage=True, fname=new_fname, wdir=new_wdir, outs=[to_path]
single_stage=True,
fname=new_fname,
wdir=new_wdir,
outs=[to_path],
meta=stage.meta,
)
os.unlink(stage.path)
+6 -2
View File
@@ -24,7 +24,7 @@ def push(
expanded_targets = glob_targets(targets, glob=glob)
used = self.used_cache(
used = self.used_objs(
expanded_targets,
all_branches=all_branches,
all_tags=all_tags,
@@ -38,4 +38,8 @@ def push(
revs=revs,
)
return len(used_run_cache) + self.cloud.push(used, jobs, remote=remote)
pushed = len(used_run_cache)
for odb, objs in used.items():
if odb is None:
pushed += self.cloud.push(objs, jobs, remote=remote)
return pushed
+18 -14
View File
@@ -78,7 +78,7 @@ def _cloud_status(
"""
import dvc.remote.base as cloud
used = self.used_cache(
used = self.used_objs(
targets,
all_branches=all_branches,
all_tags=all_tags,
@@ -91,22 +91,26 @@ def _cloud_status(
)
ret = {}
status_info = self.cloud.status(
used, jobs, remote=remote, log_missing=False
)
for info in status_info.values():
name = info["name"]
status_ = info["status"]
if status_ == cloud.STATUS_OK:
for odb, objs in used.items():
if odb is not None:
# ignore imported objects
continue
status_info = self.cloud.status(
objs, jobs, remote=remote, log_missing=False
)
for info in status_info.values():
name = info["name"]
status_ = info["status"]
if status_ == cloud.STATUS_OK:
continue
prefix_map = {
cloud.STATUS_DELETED: "deleted",
cloud.STATUS_NEW: "new",
cloud.STATUS_MISSING: "missing",
}
prefix_map = {
cloud.STATUS_DELETED: "deleted",
cloud.STATUS_NEW: "new",
cloud.STATUS_MISSING: "missing",
}
ret[name] = prefix_map[status_]
ret[name] = prefix_map[status_]
return ret
+11 -8
View File
@@ -4,7 +4,7 @@ import string
from collections import defaultdict
from contextlib import suppress
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, Optional
from typing import TYPE_CHECKING, Dict, Optional, Set
from funcy import cached_property, project
@@ -36,6 +36,8 @@ from .utils import (
if TYPE_CHECKING:
from dvc.dvcfile import DVCFile
from dvc.objects.db.base import ObjectDB
from dvc.objects.file import HashFile
logger = logging.getLogger(__name__)
# Disallow all punctuation characters except hyphen and underscore
@@ -622,14 +624,15 @@ class Stage(params.StageParams):
for out in self.filter_outs(filter_info)
)
def get_used_cache(self, *args, **kwargs):
from dvc.objects.db import NamedCache
cache = NamedCache()
def get_used_objs(
self, *args, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
"""Return set of objects used by this stage."""
used_objs = defaultdict(set)
for out in self.filter_outs(kwargs.get("filter_info")):
cache.update(out.get_used_cache(*args, **kwargs))
return cache
for odb, objs in out.get_used_objs(*args, **kwargs).items():
used_objs[odb].update(objs)
return used_objs
@staticmethod
def _check_can_merge(stage, ancestor_out=None):
+18 -14
View File
@@ -196,7 +196,8 @@ class StageCache:
cached_stage = self._create_stage(cache, wdir=stage.wdir)
if pull:
self.repo.cloud.pull(cached_stage.get_used_cache())
for objs in cached_stage.get_used_objs().values():
self.repo.cloud.pull(objs)
if not cached_stage.outs_cached():
raise RunCacheNotFoundError(stage)
@@ -212,15 +213,17 @@ class StageCache:
ret = []
runs = from_remote.path_info / "runs"
if not from_remote.exists(runs):
if not from_remote.fs.exists(runs):
return []
for src in from_remote.walk_files(runs):
for src in from_remote.fs.walk_files(runs):
rel = src.relative_to(from_remote.path_info)
dst = to_remote.path_info / rel
key = dst.parent
# check if any build cache already exists for this key
if to_remote.exists(key) and first(to_remote.walk_files(key)):
if to_remote.fs.exists(key) and first(
to_remote.fs.walk_files(key)
):
continue
func(src, dst)
ret.append((src.parent.name, src.name))
@@ -233,8 +236,8 @@ class StageCache:
remote = self.repo.cloud.get_remote(remote)
return self._transfer(
_log_exceptions(remote.fs.upload, "upload"),
self.repo.odb.local.fs,
remote.fs,
self.repo.odb.local,
remote.odb,
)
def pull(self, remote):
@@ -243,19 +246,20 @@ class StageCache:
remote = self.repo.cloud.get_remote(remote)
return self._transfer(
_log_exceptions(remote.fs.download, "download"),
remote.fs,
self.repo.odb.local.fs,
remote.odb,
self.repo.odb.local,
)
def get_used_cache(self, used_run_cache, *args, **kwargs):
from dvc.objects.db import NamedCache
cache = NamedCache()
def get_used_objs(self, used_run_cache, *args, **kwargs):
"""Return used cache for the specified run-cached stages."""
from collections import defaultdict
used_objs = defaultdict(set)
for key, value in used_run_cache:
entry = self._load_cache(key, value)
if not entry:
continue
stage = self._create_stage(entry)
cache.update(stage.get_used_cache(*args, **kwargs))
return cache
for odb, objs in stage.get_used_objs(*args, **kwargs).items():
used_objs[odb].update(objs)
return used_objs
+1
View File
@@ -75,6 +75,7 @@ class CheckpointTask(MonitorTask):
def _run_callback(stage, callback_func):
stage.save(allow_missing=True)
stage.commit(allow_missing=True)
stage.unprotect_outs()
logger.debug("Running checkpoint callback for stage '%s'", stage)
callback_func()
+11 -6
View File
@@ -1,5 +1,4 @@
import sys
from collections import defaultdict
from typing import (
TYPE_CHECKING,
Any,
@@ -7,20 +6,21 @@ from typing import (
Iterable,
Optional,
Sequence,
TextIO,
Union,
)
from funcy import cached_property
from dvc.progress import Tqdm
from dvc.utils import colorize
if TYPE_CHECKING:
from dvc.progress import Tqdm
from dvc.ui.table import Headers, Styles, TableData
class Formatter:
def __init__(self, theme: Dict = None, defaults: Dict = None) -> None:
from collections import defaultdict
theme = theme or {
"success": {"color": "green", "style": "bold"},
"warn": {"color": "yellow"},
@@ -29,6 +29,8 @@ class Formatter:
self.theme = defaultdict(lambda: defaults or {}, theme)
def format(self, message: str, style: str = None, **kwargs) -> str:
from dvc.utils import colorize
return colorize(message, **self.theme[style])
@@ -75,6 +77,7 @@ class Console:
stderr: bool = False,
force: bool = False,
styled: bool = False,
file: TextIO = None,
) -> None:
sep = " " if sep is None else sep
end = "\n" if end is None else end
@@ -85,12 +88,14 @@ class Console:
console = self.error_console if stderr else self.rich_console
return console.print(*objects, sep=sep, end=end)
file = sys.stderr if stderr else sys.stdout
file = file or (sys.stderr if stderr else sys.stdout)
values = (self.formatter.format(obj, style=style) for obj in objects)
return print(*values, sep=sep, end=end, file=file)
@staticmethod
def progress(*args, **kwargs) -> Tqdm:
def progress(*args, **kwargs) -> "Tqdm":
from dvc.progress import Tqdm
return Tqdm(*args, **kwargs)
def prompt(
+13 -4
View File
@@ -319,10 +319,19 @@ def relpath(path, start=os.curdir):
start = os.path.abspath(os.fspath(start))
# Windows path on different drive than curdir doesn't have relpath
if os.name == "nt" and not os.path.commonprefix(
[start, os.path.abspath(path)]
):
return path
if os.name == "nt":
# Since python 3.8 os.realpath resolves network shares to their UNC
# path. So, to be certain that relative paths correctly captured,
# we need to resolve to UNC path first. We resolve only the drive
# name so that we don't follow any 'real' symlinks on the path
def resolve_network_drive_windows(path_to_resolve):
drive, tail = os.path.splitdrive(path_to_resolve)
return os.path.join(os.path.realpath(drive), tail)
path = resolve_network_drive_windows(os.path.abspath(path))
start = resolve_network_drive_windows(start)
if not os.path.commonprefix([start, path]):
return path
return os.path.relpath(path, start)
+14 -2
View File
@@ -3,6 +3,7 @@ import os
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Callable, ContextManager, Dict, Union
from funcy import reraise
from typing_extensions import Protocol
from dvc.exceptions import DvcException
@@ -42,13 +43,24 @@ class ParseError(DvcException):
from dvc.utils import relpath
path = relpath(path)
self.path = path
super().__init__(f"unable to read: '{path}', {message}")
class EncodingError(ParseError):
"""We could not read a file with the given encoding"""
def __init__(self, path: "AnyPath", encoding: str):
self.encoding = encoding
super().__init__(path, f"is not valid {encoding}")
def _load_data(path: "AnyPath", parser: ParserFn, fs: "BaseFileSystem" = None):
open_fn = fs.open if fs else open
with open_fn(path, encoding="utf-8") as fd: # type: ignore
return parser(fd.read(), path)
encoding = "utf-8"
with open_fn(path, encoding=encoding) as fd: # type: ignore
with reraise(UnicodeDecodeError, EncodingError(path, encoding)):
return parser(fd.read(), path)
def _dump_data(path, data: Any, dumper: DumperFn, fs: "BaseFileSystem" = None):
+5 -1
View File
@@ -70,6 +70,7 @@ class HashedStreamReader(io.IOBase):
def __init__(self, fobj):
self.fobj = fobj
self.md5 = hashlib.md5()
self.total_read = 0
self.is_text_file = None
super().__init__()
@@ -95,9 +96,12 @@ class HashedStreamReader(io.IOBase):
else:
data = chunk
self.md5.update(data)
self.total_read += len(data)
return chunk
@property
def hash_info(self):
return HashInfo(self.PARAM_CHECKSUM, self.md5.hexdigest(), nfiles=1)
return HashInfo(
self.PARAM_CHECKSUM, self.md5.hexdigest(), size=self.total_read
)
+1 -1
View File
@@ -6,7 +6,7 @@
import os
import subprocess
_BASE_VERSION = "2.2.0"
_BASE_VERSION = "2.4.1"
def _generate_version(base_version):
+5 -5
View File
@@ -73,7 +73,7 @@ install_requires = [
"flufl.lock>=3.2,<4",
"win-unicode-console>=0.5; sys_platform == 'win32'",
"pywin32>=225; sys_platform == 'win32'",
"networkx>=2.1",
"networkx~=2.5",
"psutil>=5.8.0",
"pydot>=1.2.4",
"speedcopy>=2.0.1; python_version < '3.8' and sys_platform == 'win32'",
@@ -88,16 +88,16 @@ install_requires = [
"python-benedict>=0.21.1",
"pyparsing==2.4.7",
"typing_extensions>=3.7.4",
"fsspec==2021.5.0",
"fsspec==2021.6.0",
"diskcache>=5.2.1",
]
# Extra dependencies for remote integrations
gs = ["gcsfs==2021.5.0"]
gs = ["gcsfs==2021.6.0"]
gdrive = ["pydrive2>=1.8.1", "six >= 1.13.0"]
s3 = ["s3fs==2021.5.0", "aiobotocore[boto3]==1.3.0"]
s3 = ["s3fs==2021.6.0", "aiobotocore[boto3]==1.3.0"]
azure = ["adlfs==0.7.1", "azure-identity>=1.4.0", "knack"]
# https://github.com/Legrandin/pycryptodome/issues/465
oss = ["oss2==2.6.1", "pycryptodome>=3.10"]
@@ -106,7 +106,7 @@ ssh = ["paramiko[invoke]>=2.7.0"]
# Remove the env marker if/when pyarrow is available for Python3.9
hdfs = ["pyarrow>=2.0.0"]
webhdfs = ["hdfs==2.5.8"]
webdav = ["webdavclient3>=3.14.5"]
webdav = ["webdav4>=0.8.1"]
# gssapi should not be included in all_remotes, because it doesn't have wheels
# for linux and mac, so it will fail to compile if user doesn't have all the
# requirements, including kerberos itself. Once all the wheels are available,
+9 -5
View File
@@ -2,12 +2,12 @@ wheel==0.36.2
# Test requirements
pytest==6.2.4
pytest-cov==2.12.0
pytest-xdist==2.2.1
pytest-cov==2.12.1
pytest-xdist==2.3.0
pytest-mock==3.6.1
pytest-lazy-fixture==0.6.3
pytest-timeout==1.4.2
pytest-docker==0.10.1
pytest-docker==0.10.3
flaky==3.7.0
mock==4.0.3
@@ -31,7 +31,7 @@ pydocstyle==6.1.1
jaraco.windows==5.5.0
# pylint requirements
pylint==2.8.2
pylint==2.8.3
# we use this to suppress pytest-related false positives in our tests.
pylint-pytest==1.0.3
# we use this to suppress some messages in tests, eg: foo/bar naming,
@@ -39,7 +39,11 @@ pylint-pytest==1.0.3
pylint-plugin-utils==0.6
# type-checking
mypy==0.812
mypy==0.902
types-requests
types-paramiko
types-tabulate
types-toml
# pypi doesn't allow for direct dependencies
# gdrivefs @ git+https://github.com/intake/gdrivefs.git
+5 -1
View File
@@ -7,9 +7,13 @@ from dvc.repo.experiments.base import EXEC_APPLY, EXEC_CHECKPOINT
@pytest.mark.parametrize("workspace", [True, False])
@pytest.mark.parametrize("links", ["reflink,copy", "hardlink,symlink"])
def test_new_checkpoint(
tmp_dir, scm, dvc, checkpoint_stage, mocker, workspace
tmp_dir, scm, dvc, checkpoint_stage, mocker, workspace, links
):
with dvc.config.edit() as conf:
conf["cache"]["type"] = links
new_mock = mocker.spy(dvc.experiments, "new")
results = dvc.experiments.run(
checkpoint_stage.addressing, params=["foo=2"], tmp_dir=not workspace
+4 -3
View File
@@ -683,17 +683,18 @@ def test_show_no_repo(tmp_dir):
dvc.plots.show(["metric.json"])
def test_show_from_subdir(tmp_dir, dvc, caplog):
def test_show_from_subdir(tmp_dir, dvc, capsys):
subdir = tmp_dir / "subdir"
subdir.mkdir()
metric = [{"first_val": 100, "val": 2}, {"first_val": 200, "val": 3}]
_write_json(subdir, metric, "metric.json")
with subdir.chdir(), caplog.at_level(logging.INFO, "dvc"):
with subdir.chdir():
assert main(["plots", "show", "metric.json"]) == 0
assert subdir.as_uri() in caplog.text
out, _ = capsys.readouterr()
assert subdir.as_uri() in out
assert (subdir / "plots.html").exists()
+3
View File
@@ -1024,6 +1024,7 @@ def test_add_to_remote(tmp_dir, dvc, local_cloud, local_remote):
hash_info = stage.outs[0].hash_info
assert local_remote.hash_to_path_info(hash_info.value).read_text() == "foo"
assert hash_info.size == len("foo")
def test_add_to_remote_absolute(tmp_dir, make_tmp_dir, dvc, local_remote):
@@ -1067,6 +1068,8 @@ def test_add_to_cache_dir(tmp_dir, dvc, local_cloud):
(stage,) = dvc.add(str(local_cloud / "data"), out="data")
assert len(stage.deps) == 0
assert len(stage.outs) == 1
assert stage.outs[0].hash_info.size == len("foo") + len("bar")
assert stage.outs[0].hash_info.nfiles == 2
data = tmp_dir / "data"
assert data.read_text() == {"foo": "foo", "bar": "bar"}
+30 -29
View File
@@ -98,7 +98,7 @@ def test_config_set_local(tmp_dir, dvc):
),
],
)
def test_config_get(tmp_dir, dvc, caplog, args, ret, msg):
def test_config_get(tmp_dir, dvc, capsys, caplog, args, ret, msg):
(tmp_dir / ".dvc" / "config").write_text(
textwrap.dedent(
"""\
@@ -123,12 +123,12 @@ def test_config_get(tmp_dir, dvc, caplog, args, ret, msg):
)
)
caplog.clear()
assert main(["config"] + args) == ret
assert msg in caplog.text
text = caplog.text if ret else capsys.readouterr()[0]
assert msg in text
def test_config_list(tmp_dir, dvc, caplog):
def test_config_list(tmp_dir, dvc, capsys):
(tmp_dir / ".dvc" / "config").write_text(
textwrap.dedent(
"""\
@@ -155,17 +155,18 @@ def test_config_list(tmp_dir, dvc, caplog):
)
)
caplog.clear()
assert main(["config", "--list"]) == 0
assert "remote.myremote.url=s3://bucket/path" in caplog.text
assert "remote.myremote.region=us-east-2" in caplog.text
assert "remote.myremote.profile=iterative" in caplog.text
assert "remote.myremote.access_key_id=abcde" in caplog.text
assert "remote.myremote.secret_access_key=123456" in caplog.text
assert "remote.other.url=gs://bucket/path" in caplog.text
assert "core.analytics=False" in caplog.text
assert "core.no_scm=true" in caplog.text
assert "core.remote=myremote" in caplog.text
out, _ = capsys.readouterr()
assert "remote.myremote.url=s3://bucket/path" in out
assert "remote.myremote.region=us-east-2" in out
assert "remote.myremote.profile=iterative" in out
assert "remote.myremote.access_key_id=abcde" in out
assert "remote.myremote.secret_access_key=123456" in out
assert "remote.other.url=gs://bucket/path" in out
assert "core.analytics=False" in out
assert "core.no_scm=true" in out
assert "core.remote=myremote" in out
@pytest.mark.parametrize(
@@ -241,57 +242,56 @@ def test_load_relative_paths(dvc, field, remote_url):
)
def test_config_remote(tmp_dir, dvc, caplog):
def test_config_remote(tmp_dir, dvc, capsys):
(tmp_dir / ".dvc" / "config").write_text(
"['remote \"myremote\"']\n"
" url = s3://bucket/path\n"
" region = myregion\n"
)
caplog.clear()
assert main(["config", "remote.myremote.url"]) == 0
assert "s3://bucket/path" in caplog.text
out, _ = capsys.readouterr()
assert "s3://bucket/path" in out
caplog.clear()
assert main(["config", "remote.myremote.region"]) == 0
assert "myregion" in caplog.text
out, _ = capsys.readouterr()
assert "myregion" in out
def test_config_show_origin_single(tmp_dir, dvc, caplog):
def test_config_show_origin_single(tmp_dir, dvc, capsys):
(tmp_dir / ".dvc" / "config").write_text(
"['remote \"myremote\"']\n"
" url = s3://bucket/path\n"
" region = myregion\n"
)
caplog.clear()
assert (
main(["config", "--show-origin", "--project", "remote.myremote.url"])
== 0
)
out, _ = capsys.readouterr()
assert (
"{}\t{}\n".format(os.path.join(".dvc", "config"), "s3://bucket/path")
in caplog.text
in out
)
caplog.clear()
assert (
main(["config", "--show-origin", "--local", "remote.myremote.url"])
== 251
)
caplog.clear()
assert main(["config", "--list", "--project", "--show-origin"]) == 0
out, _ = capsys.readouterr()
assert (
"{}\t{}\n".format(
os.path.join(".dvc", "config"),
"remote.myremote.url=s3://bucket/path",
)
in caplog.text
in out
)
def test_config_show_origin_merged(tmp_dir, dvc, caplog):
def test_config_show_origin_merged(tmp_dir, dvc, capsys):
(tmp_dir / ".dvc" / "config").write_text(
"['remote \"myremote\"']\n"
" url = s3://bucket/path\n"
@@ -302,18 +302,19 @@ def test_config_show_origin_merged(tmp_dir, dvc, caplog):
"['remote \"myremote\"']\n timeout = 100\n"
)
caplog.clear()
assert main(["config", "--list", "--show-origin"]) == 0
out, _ = capsys.readouterr()
assert (
"{}\t{}\n".format(
os.path.join(".dvc", "config"),
"remote.myremote.url=s3://bucket/path",
)
in caplog.text
in out
)
assert (
"{}\t{}\n".format(
os.path.join(".dvc", "config.local"), "remote.myremote.timeout=100"
)
in caplog.text
in out
)
+81 -41
View File
@@ -8,7 +8,6 @@ from flaky.flaky_decorator import flaky
import dvc as dvc_module
from dvc.external_repo import clean_repos
from dvc.main import main
from dvc.objects.db import NamedCache
from dvc.remote.base import (
STATUS_DELETED,
STATUS_MISSING,
@@ -41,6 +40,14 @@ all_clouds = [
)
]
# Clouds that implement the general methods that can be tested
# for functional tests that require extensive apis (e.g traversing
# via walk_files())
full_clouds = [
pytest.lazy_fixture(cloud)
for cloud in ["s3", "gs", "azure", "ssh", "hdfs"]
]
@pytest.mark.needs_internet
@pytest.mark.parametrize("remote", all_clouds, indirect=True)
@@ -49,7 +56,7 @@ def test_cloud(tmp_dir, dvc, remote): # pylint:disable=unused-argument
out = stage.outs[0]
cache = out.cache_path
md5 = out.hash_info.value
info = out.get_used_cache()
foo_objs = out.get_used_objs().get(None, set())
(stage_dir,) = tmp_dir.dvc_gen(
{
@@ -62,29 +69,39 @@ def test_cloud(tmp_dir, dvc, remote): # pylint:disable=unused-argument
)
out_dir = stage_dir.outs[0]
cache_dir = out_dir.cache_path
name_dir = str(out_dir)
md5_dir = out_dir.hash_info.value
info_dir = NamedCache.make(out_dir.scheme, md5_dir, name_dir)
dir_objs = {out_dir.obj}
entry_md5s = [entry_obj.hash_info.value for _, entry_obj in out_dir.obj]
def _make_dir_status(status):
expected = {md5_dir: {"name": md5_dir, "status": status}}
expected.update(
{
entry_md5: {"name": entry_md5, "status": status}
for entry_md5 in entry_md5s
}
)
return expected
# Check status
status = dvc.cloud.status(info, show_checksums=True)
status = dvc.cloud.status(foo_objs, show_checksums=True)
expected = {md5: {"name": md5, "status": STATUS_NEW}}
assert status == expected
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
expected = {md5_dir: {"name": md5_dir, "status": STATUS_NEW}}
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
expected = _make_dir_status(STATUS_NEW)
assert status_dir == expected
# Move cache and check status
# See issue https://github.com/iterative/dvc/issues/4383 for details
backup_dir = dvc.odb.local.cache_dir + ".backup"
move(dvc.odb.local.cache_dir, backup_dir)
status = dvc.cloud.status(info, show_checksums=True)
status = dvc.cloud.status(foo_objs, show_checksums=True)
expected = {md5: {"name": md5, "status": STATUS_MISSING}}
assert status == expected
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
expected = {md5_dir: {"name": md5_dir, "status": STATUS_MISSING}}
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
expected = _make_dir_status(STATUS_MISSING)
assert status_dir == expected
# Restore original cache:
@@ -92,48 +109,48 @@ def test_cloud(tmp_dir, dvc, remote): # pylint:disable=unused-argument
move(backup_dir, dvc.odb.local.cache_dir)
# Push and check status
dvc.cloud.push(info)
dvc.cloud.push(foo_objs)
assert os.path.exists(cache)
assert os.path.isfile(cache)
dvc.cloud.push(info_dir)
dvc.cloud.push(dir_objs)
assert os.path.isfile(cache_dir)
status = dvc.cloud.status(info, show_checksums=True)
status = dvc.cloud.status(foo_objs, show_checksums=True)
expected = {md5: {"name": md5, "status": STATUS_OK}}
assert status == expected
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
expected = {md5_dir: {"name": md5_dir, "status": STATUS_OK}}
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
expected = _make_dir_status(STATUS_OK)
assert status_dir == expected
# Remove and check status
remove(dvc.odb.local.cache_dir)
status = dvc.cloud.status(info, show_checksums=True)
status = dvc.cloud.status(foo_objs, show_checksums=True)
expected = {md5: {"name": md5, "status": STATUS_DELETED}}
assert status == expected
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
expected = {md5_dir: {"name": md5_dir, "status": STATUS_DELETED}}
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
expected = _make_dir_status(STATUS_DELETED)
assert status_dir == expected
# Pull and check status
dvc.cloud.pull(info)
dvc.cloud.pull(foo_objs)
assert os.path.exists(cache)
assert os.path.isfile(cache)
with open(cache) as fd:
assert fd.read() == "foo"
dvc.cloud.pull(info_dir)
dvc.cloud.pull(dir_objs)
assert os.path.isfile(cache_dir)
status = dvc.cloud.status(info, show_checksums=True)
status = dvc.cloud.status(foo_objs, show_checksums=True)
expected = {md5: {"name": md5, "status": STATUS_OK}}
assert status == expected
status_dir = dvc.cloud.status(info_dir, show_checksums=True)
expected = {md5_dir: {"name": md5_dir, "status": STATUS_OK}}
status_dir = dvc.cloud.status(dir_objs, show_checksums=True)
expected = _make_dir_status(STATUS_OK)
assert status_dir == expected
@@ -269,7 +286,10 @@ def test_missing_cache(tmp_dir, dvc, local_remote, caplog):
assert bar in caplog.text
caplog.clear()
assert dvc.status(cloud=True) == {"bar": "missing", "foo": "missing"}
assert dvc.status(cloud=True) == {
"bar": "missing",
"foo": "missing",
}
assert header not in caplog.text
assert foo not in caplog.text
assert bar not in caplog.text
@@ -455,13 +475,12 @@ def test_pipeline_file_target_ops(tmp_dir, dvc, run_copy, local_remote):
({}, "Everything is up to date"),
],
)
def test_push_stats(tmp_dir, dvc, fs, msg, caplog, local_remote):
def test_push_stats(tmp_dir, dvc, fs, msg, capsys, local_remote):
tmp_dir.dvc_gen(fs)
caplog.clear()
with caplog.at_level(level=logging.INFO, logger="dvc"):
main(["push"])
assert msg in caplog.text
main(["push"])
out, _ = capsys.readouterr()
assert msg in out
@pytest.mark.parametrize(
@@ -472,17 +491,17 @@ def test_push_stats(tmp_dir, dvc, fs, msg, caplog, local_remote):
({}, "Everything is up to date."),
],
)
def test_fetch_stats(tmp_dir, dvc, fs, msg, caplog, local_remote):
def test_fetch_stats(tmp_dir, dvc, fs, msg, capsys, local_remote):
tmp_dir.dvc_gen(fs)
dvc.push()
clean(list(fs.keys()), dvc)
caplog.clear()
with caplog.at_level(level=logging.INFO, logger="dvc"):
main(["fetch"])
assert msg in caplog.text
main(["fetch"])
out, _ = capsys.readouterr()
assert msg in out
def test_pull_stats(tmp_dir, dvc, capsys, caplog, local_remote):
def test_pull_stats(tmp_dir, dvc, capsys, local_remote):
tmp_dir.dvc_gen({"foo": "foo", "bar": "bar"})
dvc.push()
clean(["foo", "bar"], dvc)
@@ -493,12 +512,13 @@ def test_pull_stats(tmp_dir, dvc, capsys, caplog, local_remote):
out, _ = capsys.readouterr()
assert "M\tbar".expandtabs() in out
assert "A\tfoo".expandtabs() in out
assert "2 files fetched" in caplog.text
assert "1 file added" in caplog.text
assert "1 file modified" in caplog.text
with caplog.at_level(level=logging.INFO, logger="dvc"):
main(["pull"])
assert "Everything is up to date." in caplog.text
assert "2 files fetched" in out
assert "1 file added" in out
assert "1 file modified" in out
main(["pull"])
out, _ = capsys.readouterr()
assert "Everything is up to date." in out
@pytest.mark.parametrize(
@@ -548,3 +568,23 @@ def test_pull_partial(tmp_dir, dvc, local_remote):
stats = dvc.pull(os.path.join("foo", "bar"))
assert stats["fetched"] == 1
assert (tmp_dir / "foo").read_text() == {"bar": {"baz": "baz"}}
@pytest.mark.parametrize("remote", full_clouds, indirect=True)
def test_pull_00_prefix(tmp_dir, dvc, remote, monkeypatch):
# Related: https://github.com/iterative/dvc/issues/6089
fs_type = type(dvc.cloud.get_remote("upstream").fs)
monkeypatch.setattr(fs_type, "_ALWAYS_TRAVERSE", True, raising=False)
monkeypatch.setattr(fs_type, "LIST_OBJECT_PAGE_SIZE", 256, raising=False)
# foo's md5 checksum is 00411460f7c92d2124a67ea0f4cb5f85
# bar's md5 checksum is 0000000018e6137ac2caab16074784a6
tmp_dir.dvc_gen({"foo": "363", "bar": "jk8ssl"})
dvc.push()
clean(["foo", "bar"], dvc)
stats = dvc.pull()
assert stats["fetched"] == 2
assert set(stats["added"]) == {"foo", "bar"}
+8 -11
View File
@@ -244,9 +244,8 @@ def test_walk_dont_ignore_subrepos(tmp_dir, scm, dvc):
)
def test_fs_getsize(dvc, cloud):
cloud.gen({"data": {"foo": "foo"}, "baz": "baz baz"})
cls, config = get_cloud_fs(dvc, **cloud.config)
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
fs = cls(**config)
path_info = fs.path_info
assert fs.getsize(path_info / "baz") == 7
assert fs.getsize(path_info / "data" / "foo") == 3
@@ -270,11 +269,11 @@ def test_fs_getsize(dvc, cloud):
)
def test_fs_upload_fobj(dvc, tmp_dir, cloud):
tmp_dir.gen("foo", "foo")
cls, config = get_cloud_fs(dvc, **cloud.config)
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
fs = cls(**config)
from_info = tmp_dir / "foo"
to_info = fs.path_info / "foo"
to_info = path_info / "foo"
with open(from_info, "rb") as stream:
fs.upload_fobj(stream, to_info)
@@ -297,9 +296,9 @@ def test_fs_ls(dvc, cloud):
}
}
)
cls, config = get_cloud_fs(dvc, **cloud.config)
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
fs = cls(**config)
path_info = cloud / "directory"
path_info /= "directory"
assert {os.path.basename(file_key) for file_key in fs.ls(path_info)} == {
"foo",
@@ -327,9 +326,8 @@ def test_fs_ls(dvc, cloud):
)
def test_fs_find_recursive(dvc, cloud):
cloud.gen({"data": {"foo": "foo", "bar": {"baz": "baz"}, "quux": "quux"}})
cls, config = get_cloud_fs(dvc, **cloud.config)
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
fs = cls(**config)
path_info = fs.path_info
assert {
os.path.basename(file_key) for file_key in fs.find(path_info / "data")
@@ -349,9 +347,8 @@ def test_fs_find_recursive(dvc, cloud):
)
def test_fs_find_with_etag(dvc, cloud):
cloud.gen({"data": {"foo": "foo", "bar": {"baz": "baz"}, "quux": "quux"}})
cls, config = get_cloud_fs(dvc, **cloud.config)
cls, config, path_info = get_cloud_fs(dvc, **cloud.config)
fs = cls(**config)
path_info = fs.path_info
for details in fs.find(path_info / "data", detail=True):
assert (
@@ -371,7 +368,7 @@ def test_fs_find_with_etag(dvc, cloud):
)
def test_fs_fsspec_path_management(dvc, cloud):
cloud.gen({"foo": "foo", "data": {"bar": "bar", "baz": {"foo": "foo"}}})
cls, config = get_cloud_fs(dvc, **cloud.config)
cls, config, _ = get_cloud_fs(dvc, **cloud.config)
fs = cls(**config)
root = cloud.parents[len(cloud.parents) - 1]
+52 -2
View File
@@ -530,7 +530,7 @@ def test_import_with_no_exec(tmp_dir, dvc, erepo_dir):
def test_import_with_jobs(mocker, dvc, erepo_dir):
from dvc.data_cloud import DataCloud
import dvc as dvc_module
with erepo_dir.chdir():
erepo_dir.dvc_gen(
@@ -545,7 +545,57 @@ def test_import_with_jobs(mocker, dvc, erepo_dir):
commit="init",
)
spy = mocker.spy(DataCloud, "pull")
spy = mocker.spy(dvc_module.objects, "save")
dvc.imp(os.fspath(erepo_dir), "dir1", jobs=3)
run_jobs = tuple(spy.call_args_list[0])[1].get("jobs")
assert run_jobs == 3
def test_chained_import(tmp_dir, dvc, make_tmp_dir, erepo_dir, local_cloud):
erepo_dir.add_remote(config=local_cloud.config)
with erepo_dir.chdir():
erepo_dir.dvc_gen({"dir": {"foo": "foo", "bar": "bar"}}, commit="init")
erepo_dir.dvc.push()
remove(erepo_dir.dvc.odb.local.cache_dir)
remove(os.fspath(erepo_dir / "dir"))
erepo2 = make_tmp_dir("erepo2", scm=True, dvc=True)
with erepo2.chdir():
erepo2.dvc.imp(os.fspath(erepo_dir), "dir")
erepo2.scm.add("dir.dvc")
erepo2.scm.commit("import")
remove(erepo2.dvc.odb.local.cache_dir)
remove(os.fspath(erepo2 / "dir"))
dvc.imp(os.fspath(erepo2), "dir", "dir_imported")
dst = tmp_dir / "dir_imported"
assert (dst / "foo").read_text() == "foo"
assert (dst / "bar").read_text() == "bar"
remove(dvc.odb.local.cache_dir)
remove("dir_imported")
# pulled objects should come from the original upstream repo's remote,
# no cache or remote should be needed from the intermediate repo
dvc.pull(["dir_imported.dvc"])
assert not os.path.exists(erepo_dir.dvc.odb.local.cache_dir)
assert not os.path.exists(erepo2.dvc.odb.local.cache_dir)
assert (dst / "foo").read_text() == "foo"
assert (dst / "bar").read_text() == "bar"
def test_circular_import(tmp_dir, dvc, scm, erepo_dir):
from dvc.exceptions import CircularImportError
with erepo_dir.chdir():
erepo_dir.dvc_gen({"dir": {"foo": "foo", "bar": "bar"}}, commit="init")
dvc.imp(os.fspath(erepo_dir), "dir", "dir_imported")
scm.add("dir_imported.dvc")
scm.commit("import")
with erepo_dir.chdir():
with pytest.raises(CircularImportError):
erepo_dir.dvc.imp(
os.fspath(tmp_dir), "dir_imported", "circular_import"
)
+1
View File
@@ -282,6 +282,7 @@ def test_import_url_to_remote_single_file(
hash_info = stage.outs[0].hash_info
assert local_remote.hash_to_path_info(hash_info.value).read_text() == "foo"
assert hash_info.size == len("foo")
@pytest.mark.parametrize(
+18
View File
@@ -273,6 +273,24 @@ def test_ls_repo_with_removed_dvc_dir_with_path_file(tmp_dir, dvc, scm):
match_files(files, ((("file",), True),))
def test_ls_repo_with_rev(erepo_dir):
with erepo_dir.chdir():
erepo_dir.scm_gen(FS_STRUCTURE, commit="init")
erepo_dir.dvc_gen(DVC_STRUCTURE, commit="dvc")
rev = erepo_dir.scm.list_all_commits()[1]
files = Repo.ls(os.fspath(erepo_dir), rev=rev)
match_files(
files,
(
((".dvcignore",), False),
((".gitignore",), False),
(("README.md",), False),
(("model",), False),
),
)
def test_ls_remote_repo(erepo_dir):
with erepo_dir.chdir():
erepo_dir.scm_gen(FS_STRUCTURE, commit="init")
+23 -1
View File
@@ -1,11 +1,12 @@
import os
import textwrap
import pytest
from dvc.dvcfile import DVC_FILE_SUFFIX
from dvc.exceptions import DvcException, MoveNotDataSourceError
from dvc.main import main
from dvc.utils.serialize import load_yaml
from dvc.utils.serialize import dump_yaml, load_yaml
from tests.basic_env import TestDvc, TestDvcGit
from tests.func.test_repro import TestRepro
from tests.utils import cd
@@ -244,3 +245,24 @@ def test_move_output_overlap(tmp_dir, dvc):
assert (tmp_dir / "foo.dvc").exists()
assert not (tmp_dir / "dir" / "foo").exists()
assert not (tmp_dir / "dir" / "foo.dvc").exists()
def test_move_meta(tmp_dir, dvc):
(stage,) = tmp_dir.dvc_gen("foo", "foo")
data = load_yaml(stage.path)
data["meta"] = {"custom_key": 42}
dump_yaml(stage.path, data)
dvc.move("foo", "bar")
res = (tmp_dir / "bar.dvc").read_text()
print(res)
assert res == textwrap.dedent(
"""\
outs:
- md5: acbd18db4cc2f85cedef654fccc4a4d8
size: 3
path: bar
meta:
custom_key: 42
"""
)
+1 -1
View File
@@ -88,7 +88,7 @@ class TestExternalCacheDir(TestDvc):
self.dvc.__init__()
assert self.dvc.odb.ssh.fs.path_info == ssh_url + "/tmp"
assert self.dvc.odb.ssh.path_info == ssh_url + "/tmp"
class TestSharedCacheDir(TestDir):
+14 -8
View File
@@ -204,6 +204,7 @@ def test_partial_push_n_pull(tmp_dir, dvc, tmp_path_factory, local_remote):
dvc.push()
remove(dvc.odb.local.cache_dir)
baz.collect_used_dir_cache()
with patch.object(LocalFileSystem, "_download", side_effect=Exception):
with pytest.raises(DownloadError) as download_error_info:
dvc.pull()
@@ -430,17 +431,22 @@ def test_push_incomplete_dir(tmp_dir, dvc, mocker, local_remote):
remote = dvc.cloud.get_remote("upstream")
odb = dvc.odb.local
dir_hash = stage.outs[0].hash_info.value
used = stage.get_used_cache(remote=remote)
out = stage.outs[0]
file_objs = [entry_obj for _, entry_obj in out.obj]
# remove one of the cache files for directory
file_hashes = list(used.child_keys(odb.fs.scheme, dir_hash))
remove(odb.hash_to_path_info(file_hashes[0]))
remove(odb.hash_to_path_info(file_objs[0].hash_info.value))
dvc.push()
assert not remote.fs.exists(remote.odb.hash_to_path_info(dir_hash))
assert not remote.fs.exists(remote.odb.hash_to_path_info(file_hashes[0]))
assert remote.fs.exists(remote.odb.hash_to_path_info(file_hashes[1]))
assert not remote.fs.exists(
remote.odb.hash_to_path_info(out.hash_info.value)
)
assert not remote.fs.exists(
remote.odb.hash_to_path_info(file_objs[0].hash_info.value)
)
assert remote.fs.exists(
remote.odb.hash_to_path_info(file_objs[1].hash_info.value)
)
def test_upload_exists(tmp_dir, dvc, local_remote):
@@ -451,7 +457,7 @@ def test_upload_exists(tmp_dir, dvc, local_remote):
remote.fs.CACHE_MODE = 0o644
from_info = PathInfo(tmp_dir / "foo")
to_info = remote.fs.path_info / "foo"
to_info = remote.odb.path_info / "foo"
remote.fs.upload(from_info, to_info)
assert remote.fs.exists(to_info)
+4 -4
View File
@@ -1,13 +1,13 @@
from dvc.main import main
def test_root(tmp_dir, dvc, caplog):
def test_root(tmp_dir, dvc, capsys):
assert main(["root"]) == 0
assert ".\n" in caplog.text
assert "." in capsys.readouterr()[0]
def test_root_locked(tmp_dir, dvc, caplog):
def test_root_locked(tmp_dir, dvc, capsys):
# NOTE: check that `dvc root` is not blocked with dvc lock
with dvc.lock:
assert main(["root"]) == 0
assert ".\n" in caplog.text
assert "." in capsys.readouterr()[0]
+9 -8
View File
@@ -4,13 +4,14 @@ from dvc.main import main
from tests.unit.test_info import PYTHON_VERSION_REGEX
def test_(tmp_dir, dvc, scm, caplog):
def test_(tmp_dir, dvc, scm, capsys):
assert main(["version"]) == 0
assert re.search(r"DVC version: \d+\.\d+\.\d+.*", caplog.text)
assert re.search(f"Platform: {PYTHON_VERSION_REGEX} on .*", caplog.text)
assert re.search(r"Supports: .*", caplog.text)
assert re.search(r"Cache types: .*", caplog.text)
assert re.search(r"Caches: local", caplog.text)
assert re.search(r"Remotes: None", caplog.text)
assert "Repo: dvc, git" in caplog.text
out, _ = capsys.readouterr()
assert re.search(r"DVC version: \d+\.\d+\.\d+.*", out)
assert re.search(f"Platform: {PYTHON_VERSION_REGEX} on .*", out)
assert re.search(r"Supports: .*", out)
assert re.search(r"Cache types: .*", out)
assert re.search(r"Caches: local", out)
assert re.search(r"Remotes: None", out)
assert "Repo: dvc, git" in out
+9 -7
View File
@@ -28,19 +28,12 @@ class Azure(Base, CloudURLInfo):
@cached_property
def service_client(self):
# pylint: disable=no-name-in-module
from azure.core.exceptions import ResourceNotFoundError
from azure.storage.blob import BlobServiceClient
service_client = BlobServiceClient.from_connection_string(
self.CONNECTION_STRING
)
container_client = service_client.get_container_client(self.bucket)
try: # verify that container exists
container_client.get_container_properties()
except ResourceNotFoundError:
container_client.create_container()
return service_client
@property
@@ -98,7 +91,16 @@ def azure_server(test_config, docker_compose, docker_services):
@pytest.fixture
def azure(azure_server):
from azure.core.exceptions import ResourceNotFoundError
url = f"azure://{TEST_AZURE_CONTAINER}/{uuid.uuid4()}"
ret = Azure(url)
ret.config = {"url": url, "connection_string": azure_server}
container = ret.service_client.get_container_client(TEST_AZURE_CONTAINER)
try: # verify that container exists
container.get_container_properties()
except ResourceNotFoundError:
container.create_container()
return ret
+15
View File
@@ -64,6 +64,8 @@ def oss_server(test_config, docker_compose, docker_services):
@pytest.fixture
def oss(oss_server):
import oss2
url = OSS.get_url()
ret = OSS(url)
ret.config = {
@@ -72,6 +74,19 @@ def oss(oss_server):
"oss_key_secret": EMULATOR_OSS_ACCESS_KEY_SECRET,
"oss_endpoint": oss_server,
}
auth = oss2.Auth(
EMULATOR_OSS_ACCESS_KEY_ID, EMULATOR_OSS_ACCESS_KEY_SECRET
)
bucket = oss2.Bucket(auth, oss_server, TEST_OSS_REPO_BUCKET)
try:
bucket.get_bucket_info()
except oss2.exceptions.NoSuchBucket:
bucket.create_bucket(
oss2.BUCKET_ACL_PUBLIC_READ,
oss2.models.BucketCreateConfig(oss2.BUCKET_STORAGE_CLASS_STANDARD),
)
return ret
+8 -8
View File
@@ -21,25 +21,25 @@ class Webdav(Base, WebDAVURLInfo):
@cached_property
def client(self):
from webdav3.client import Client
from webdav4.client import Client
user, secrets = first(AUTH.items())
return Client(
{
"webdav_hostname": self.replace(path="").url,
"webdav_login": user,
"webdav_password": secrets["password"],
}
self.replace(path="").url, auth=(user, secrets["password"])
)
def mkdir(self, mode=0o777, parents=False, exist_ok=False):
assert mode == 0o777
parent_dirs = list(reversed(self.parents))[1:] if parents else []
for d in parent_dirs + [self]:
self.client.mkdir(d.path) # pylint: disable=no-member
path = d.path # pylint: disable=no-member
if not self.client.exists(path):
self.client.mkdir(path)
def write_bytes(self, contents):
self.client.upload_to(contents, self.path)
from io import BytesIO
self.client.upload_fileobj(BytesIO(contents), self.path)
@pytest.fixture
+36 -41
View File
@@ -1,5 +1,4 @@
import collections
import logging
import os
import mock
@@ -21,7 +20,7 @@ def test_digest(checksum, expected):
assert expected == _digest(checksum)
def test_default(mocker, caplog):
def test_default(mocker, capsys):
args = parse_args(["diff"])
cmd = args.func(args)
diff = {
@@ -51,10 +50,10 @@ def test_default(mocker, caplog):
"\n"
"files summary: 1 added, 0 deleted, "
"1 renamed, 0 modified, 0 not in cache"
).format(sep=os.path.sep) in caplog.text
).format(sep=os.path.sep) in capsys.readouterr()[0]
def test_show_hash(mocker, caplog):
def test_show_hash(mocker, capsys):
args = parse_args(["diff", "--show-hash"])
cmd = args.func(args)
diff = {
@@ -81,6 +80,8 @@ def test_show_hash(mocker, caplog):
}
mocker.patch("dvc.repo.Repo.diff", return_value=diff)
assert 0 == cmd.run()
out, _ = capsys.readouterr()
assert (
"Deleted:\n"
" XXXXXXXX " + os.path.join("data", "") + "\n"
@@ -100,10 +101,10 @@ def test_show_hash(mocker, caplog):
"\n"
"files summary: 0 added, 2 deleted, "
"1 renamed, 2 modified, 0 not in cache"
) in caplog.text
) in out
def test_show_json(mocker, caplog):
def test_show_json(mocker, capsys):
args = parse_args(["diff", "--show-json"])
cmd = args.func(args)
diff = {
@@ -118,13 +119,14 @@ def test_show_json(mocker, caplog):
mocker.patch("dvc.repo.Repo.diff", return_value=diff)
assert 0 == cmd.run()
assert '"added": [{"path": "file1"}, {"path": "file2"}]' in caplog.text
assert '"deleted": []' in caplog.text
assert '"modified": []' in caplog.text
assert '"not in cache": []' in caplog.text
out, _ = capsys.readouterr()
assert '"added": [{"path": "file1"}, {"path": "file2"}]' in out
assert '"deleted": []' in out
assert '"modified": []' in out
assert '"not in cache": []' in out
def test_show_json_and_hash(mocker, caplog):
def test_show_json_and_hash(mocker, capsys):
args = parse_args(["diff", "--show-json", "--show-hash"])
cmd = args.func(args)
@@ -147,20 +149,21 @@ def test_show_json_and_hash(mocker, caplog):
mocker.patch("dvc.repo.Repo.diff", return_value=diff)
assert 0 == cmd.run()
out, _ = capsys.readouterr()
assert (
'"added": [{"path": "file1", "hash": "11111111"}, '
'{"path": "file2", "hash": "22222222"}]' in caplog.text
'{"path": "file2", "hash": "22222222"}]' in out
)
assert '"deleted": []' in caplog.text
assert '"modified": []' in caplog.text
assert '"deleted": []' in out
assert '"modified": []' in out
assert (
'"renamed": [{"path": {"old": "file_old", '
'"new": "file_new"}, "hash": "11111111"}]' in caplog.text
'"new": "file_new"}, "hash": "11111111"}]' in out
)
assert '"not in cache": []' in caplog.text
assert '"not in cache": []' in out
def test_show_json_hide_missing(mocker, caplog):
def test_show_json_hide_missing(mocker, capsys):
args = parse_args(["diff", "--show-json", "--hide-missing"])
cmd = args.func(args)
diff = {
@@ -181,19 +184,17 @@ def test_show_json_hide_missing(mocker, caplog):
mocker.patch("dvc.repo.Repo.diff", return_value=diff)
assert 0 == cmd.run()
assert '"added": [{"path": "file1"}, {"path": "file2"}]' in caplog.text
assert '"deleted": []' in caplog.text
assert (
'"renamed": [{"path": {"old": "file_old", "new": "file_new"}'
in caplog.text
)
assert '"modified": []' in caplog.text
assert '"not in cache": []' not in caplog.text
out, _ = capsys.readouterr()
assert '"added": [{"path": "file1"}, {"path": "file2"}]' in out
assert '"deleted": []' in out
assert '"renamed": [{"path": {"old": "file_old", "new": "file_new"}' in out
assert '"modified": []' in out
assert '"not in cache": []' not in out
@pytest.mark.parametrize("show_hash", [None, True, False])
@mock.patch("dvc.command.diff._show_md")
def test_diff_show_md_and_hash(mock_show_md, mocker, caplog, show_hash):
def test_diff_show_md_and_hash(mock_show_md, mocker, show_hash):
options = ["diff", "--show-md"] + (["--show-hash"] if show_hash else [])
args = parse_args(options)
cmd = args.func(args)
@@ -206,27 +207,20 @@ def test_diff_show_md_and_hash(mock_show_md, mocker, caplog, show_hash):
mock_show_md.assert_called_once_with(diff, show_hash, False)
def test_no_changes(mocker, caplog):
def test_no_changes(mocker, capsys):
args = parse_args(["diff", "--show-json"])
cmd = args.func(args)
mocker.patch("dvc.repo.Repo.diff", return_value={})
def info():
return [
msg
for name, level, msg in caplog.record_tuples
if name.startswith("dvc") and level == logging.INFO
]
assert 0 == cmd.run()
assert ["{}"] == info()
caplog.clear()
out, _ = capsys.readouterr()
assert "{}" in out
args = parse_args(["diff"])
cmd = args.func(args)
assert 0 == cmd.run()
assert not info()
out, _ = capsys.readouterr()
assert not out
def test_show_md(capsys):
@@ -330,7 +324,7 @@ def test_show_md_hide_missing(capsys):
).format(sep=os.path.sep)
def test_hide_missing(mocker, caplog):
def test_hide_missing(mocker, capsys):
args = parse_args(["diff", "--hide-missing"])
cmd = args.func(args)
diff = {
@@ -348,6 +342,7 @@ def test_hide_missing(mocker, caplog):
mocker.patch("dvc.repo.Repo.diff", return_value=diff)
assert 0 == cmd.run()
out, _ = capsys.readouterr()
assert (
"Added:\n"
" file\n"
@@ -356,5 +351,5 @@ def test_hide_missing(mocker, caplog):
" file_old -> file_new\n"
"\n"
"files summary: 1 added, 0 deleted, 1 renamed, 0 modified"
) in caplog.text
assert "not in cache" not in caplog.text
) in out
assert "not in cache" not in out
+3 -2
View File
@@ -28,7 +28,7 @@ def test_get(mocker):
)
def test_get_url(mocker, caplog):
def test_get_url(mocker, capsys):
cli_args = parse_args(
["get", "repo_url", "src", "--rev", "version", "--show-url"]
)
@@ -38,6 +38,7 @@ def test_get_url(mocker, caplog):
m = mocker.patch("dvc.api.get_url", return_value="resource_url")
assert cmd.run() == 0
assert "resource_url" in caplog.text
out, _ = capsys.readouterr()
assert "resource_url" in out
m.assert_called_once_with("src", repo="repo_url", rev="version")
+6 -7
View File
@@ -50,7 +50,7 @@ def test_metrics_diff(dvc, mocker, capsys):
)
def test_metrics_diff_json(dvc, mocker, caplog, capsys):
def test_metrics_diff_json(dvc, mocker, capsys):
cli_args = parse_args(
[
"metrics",
@@ -79,6 +79,7 @@ def test_metrics_diff_json(dvc, mocker, caplog, capsys):
show_diff_mock = mocker.patch("dvc.compare.show_diff")
assert cmd.run() == 0
out, _ = capsys.readouterr()
metrics_diff.assert_called_once_with(
cmd.repo,
targets=["target1", "target2"],
@@ -88,8 +89,7 @@ def test_metrics_diff_json(dvc, mocker, caplog, capsys):
all=True,
)
show_diff_mock.assert_not_called()
assert json.dumps(diff) in caplog.text
assert capsys.readouterr() == ("", "")
assert json.dumps(diff) in out
def test_metrics_show(dvc, mocker):
@@ -134,7 +134,7 @@ def test_metrics_show(dvc, mocker):
)
def test_metrics_show_json(dvc, mocker, caplog, capsys):
def test_metrics_show_json(dvc, mocker, capsys):
cli_args = parse_args(
[
"metrics",
@@ -161,7 +161,7 @@ def test_metrics_show_json(dvc, mocker, caplog, capsys):
show_metrics_mock = mocker.patch("dvc.compare.show_metrics")
assert cmd.run() == 0
out, _ = capsys.readouterr()
metrics_show.assert_called_once_with(
cmd.repo,
["target1", "target2"],
@@ -171,5 +171,4 @@ def test_metrics_show_json(dvc, mocker, caplog, capsys):
all_commits=True,
)
show_metrics_mock.assert_not_called()
assert json.dumps(d) in caplog.text
assert capsys.readouterr() == ("", "")
assert json.dumps(d) in out
+4 -8
View File
@@ -1,5 +1,3 @@
import logging
from dvc.cli import parse_args
from dvc.command.params import CmdParamsDiff
@@ -57,7 +55,7 @@ def test_params_diff_from_cli(dvc, mocker):
)
def test_params_diff_show_json(dvc, mocker, caplog, capsys):
def test_params_diff_show_json(dvc, mocker, capsys):
cli_args = parse_args(
["params", "diff", "HEAD~10", "HEAD~1", "--show-json"]
)
@@ -67,9 +65,7 @@ def test_params_diff_show_json(dvc, mocker, caplog, capsys):
)
show_diff_mock = mocker.patch("dvc.compare.show_diff")
with caplog.at_level(logging.INFO, logger="dvc"):
assert cmd.run() == 0
assert '{"params.yaml": {"a": "b"}}\n' in caplog.text
assert cmd.run() == 0
out, _ = capsys.readouterr()
assert '{"params.yaml": {"a": "b"}}\n' in out
show_diff_mock.assert_not_called()
assert capsys.readouterr() == ("", "")
+16 -12
View File
@@ -1,4 +1,3 @@
import logging
import os
import posixpath
@@ -91,7 +90,7 @@ def test_plots_show_vega(dvc, mocker):
)
def test_plots_diff_vega(dvc, mocker, caplog):
def test_plots_diff_vega(dvc, mocker, capsys):
cli_args = parse_args(
[
"plots",
@@ -108,10 +107,12 @@ def test_plots_diff_vega(dvc, mocker, caplog):
"dvc.repo.plots.diff.diff", return_value={"plots.csv": "plothtml"}
)
assert cmd.run() == 0
assert "plothtml" in caplog.text
out, _ = capsys.readouterr()
assert "plothtml" in out
def test_plots_diff_open(tmp_dir, dvc, mocker, caplog):
def test_plots_diff_open(tmp_dir, dvc, mocker, capsys):
mocked_open = mocker.patch("webbrowser.open", return_value=True)
cli_args = parse_args(["plots", "diff", "--targets", "datafile", "--open"])
cmd = cli_args.func(cli_args)
@@ -123,10 +124,12 @@ def test_plots_diff_open(tmp_dir, dvc, mocker, caplog):
mocked_open.assert_called_once_with("plots.html")
expected_url = posixpath.join(tmp_dir.as_uri(), "plots.html")
assert expected_url in caplog.text
out, _ = capsys.readouterr()
assert expected_url in out
def test_plots_diff_open_failed(tmp_dir, dvc, mocker, caplog):
def test_plots_diff_open_failed(tmp_dir, dvc, mocker, capsys):
mocked_open = mocker.patch("webbrowser.open", return_value=False)
cli_args = parse_args(["plots", "diff", "--targets", "datafile", "--open"])
cmd = cli_args.func(cli_args)
@@ -140,10 +143,9 @@ def test_plots_diff_open_failed(tmp_dir, dvc, mocker, caplog):
error_message = "Failed to open. Please try opening it manually."
expected_url = posixpath.join(tmp_dir.as_uri(), "plots.html")
assert caplog.record_tuples == [
("dvc.command.plots", logging.INFO, expected_url),
("dvc.command.plots", logging.ERROR, error_message),
]
out, err = capsys.readouterr()
assert expected_url in out
assert error_message in err
@pytest.mark.parametrize(
@@ -155,7 +157,7 @@ def test_plots_diff_open_failed(tmp_dir, dvc, mocker, caplog):
ids=["quote", "resolve"],
)
def test_plots_path_is_quoted_and_resolved_properly(
tmp_dir, dvc, mocker, caplog, output, expected_url_path
tmp_dir, dvc, mocker, capsys, output, expected_url_path
):
cli_args = parse_args(
["plots", "diff", "--targets", "datafile", "--out", output]
@@ -167,4 +169,6 @@ def test_plots_path_is_quoted_and_resolved_properly(
assert cmd.run() == 0
expected_url = posixpath.join(tmp_dir.as_uri(), expected_url_path)
assert expected_url in caplog.text
out, _ = capsys.readouterr()
assert expected_url in out
+4 -3
View File
@@ -45,16 +45,17 @@ def test_cloud_status(tmp_dir, dvc, mocker):
@pytest.mark.parametrize("status", [{}, {"a": "b", "c": [1, 2, 3]}, [1, 2, 3]])
def test_status_show_json(dvc, mocker, caplog, status):
def test_status_show_json(dvc, mocker, capsys, status):
cli_args = parse_args(["status", "--show-json"])
assert cli_args.func == CmdDataStatus
cmd = cli_args.func(cli_args)
mocker.patch.object(cmd.repo, "status", autospec=True, return_value=status)
caplog.clear()
assert cmd.run() == 0
assert caplog.messages == [json.dumps(status)]
out, _ = capsys.readouterr()
assert json.dumps(status) in out
@pytest.mark.parametrize(
+78 -9
View File
@@ -3,7 +3,7 @@ from concurrent.futures import ThreadPoolExecutor
import pytest
from dvc.fs.azure import AzureFileSystem, _temp_event_loop
from dvc.fs.azure import AzureAuthError, AzureFileSystem, _temp_event_loop
from dvc.path_info import PathInfo
container_name = "container-name"
@@ -15,21 +15,27 @@ connection_string = (
)
def test_init_env_var(monkeypatch, dvc):
def test_strip_protocol_env_var(monkeypatch, dvc):
monkeypatch.setenv("AZURE_STORAGE_CONTAINER_NAME", container_name)
monkeypatch.setenv("AZURE_STORAGE_CONNECTION_STRING", connection_string)
config = {"url": "azure://"}
fs = AzureFileSystem(**config)
assert fs.path_info == "azure://" + container_name
assert (
AzureFileSystem._strip_protocol("azure://")
== f"azure://{container_name}"
)
def test_strip_protocol(dvc):
assert (
AzureFileSystem._strip_protocol(f"azure://{container_name}")
== f"azure://{container_name}"
)
def test_init(dvc):
prefix = "some/prefix"
url = f"azure://{container_name}/{prefix}"
config = {"url": url, "connection_string": connection_string}
config = {"connection_string": connection_string}
fs = AzureFileSystem(**config)
assert fs.path_info == url
assert fs.fs_args["connection_string"] == connection_string
def test_info(tmp_dir, azure):
@@ -67,3 +73,66 @@ def test_temp_event_loop():
future = executor.submit(wrapped_procedure)
assert future.result() == "yeey"
def test_azure_login_methods():
def get_login_method(config):
fs = AzureFileSystem(**config)
# pylint: disable=pointless-statement
return fs.login_method
with pytest.raises(AzureAuthError):
get_login_method({})
assert (
get_login_method({"connection_string": "test"}) == "connection string"
)
assert get_login_method({"account_name": "test"}).startswith(
"default credentials"
)
assert (
get_login_method(
{"account_name": "test", "allow_anonymous_login": True}
)
== "anonymous login"
)
with pytest.raises(AzureAuthError):
get_login_method(
{"tenant_id": "test", "client_id": "test", "client_secret": "test"}
)
assert (
get_login_method(
{
"account_name": "test",
"tenant_id": "test",
"client_id": "test",
"client_secret": "test",
}
)
== "AD service principal"
)
assert (
get_login_method({"account_name": "test", "account_key": "test"})
== "account key"
)
assert (
get_login_method({"account_name": "test", "sas_token": "test"})
== "SAS token"
)
assert (
get_login_method(
{
"connection_string": "test",
"account_name": "test",
"sas_token": "test",
}
)
== "connection string"
)
assert (
get_login_method({"connection_string": "test", "sas_token": "test"})
== "connection string"
)
+32 -8
View File
@@ -26,18 +26,19 @@ def grants():
}
def test_init(dvc):
config = {"url": url}
fs = S3FileSystem(**config)
assert fs.path_info == url
def test_verify_ssl_default_param(dvc):
config = {"url": url}
fs = S3FileSystem(**config)
assert fs.fs_args["client_kwargs"]["verify"]
assert "client_kwargs" not in fs.fs_args
config = {
"url": url,
"endpointurl": "https://my.custom.s3:1234",
}
fs = S3FileSystem(**config)
assert "verify" not in fs.fs_args["client_kwargs"]
def test_s3_config_credentialpath(dvc, monkeypatch):
@@ -72,6 +73,29 @@ def test_ssl_verify_bool_param(dvc):
assert fs.fs_args["client_kwargs"]["verify"] == config["ssl_verify"]
def test_ssl_verify_path_param(dvc):
config = {"url": url, "ssl_verify": "/path/to/custom/cabundle.pem"}
fs = S3FileSystem(**config)
assert fs.fs_args["client_kwargs"]["verify"] == config["ssl_verify"]
def test_ssl_verify_none_param(dvc):
config = {"url": url, "ssl_verify": None}
fs = S3FileSystem(**config)
assert "client_kwargs" not in fs.fs_args
config = {
"url": url,
"endpointurl": "https://my.custom.s3:1234",
"ssl_verify": None,
}
fs = S3FileSystem(**config)
assert "verify" not in fs.fs_args["client_kwargs"]
def test_grants(dvc):
config = {
"url": url,

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