Merge pull request #24 from dmpetrov/elh_dev

WIP gc support
This commit is contained in:
Earl
2017-04-17 17:08:38 -07:00
committed by GitHub
10 changed files with 457 additions and 55 deletions
+144 -1
View File
@@ -2,7 +2,99 @@
[![Test Coverage](https://codeclimate.com/github/dmpetrov/dataversioncontrol/badges/coverage.svg)](https://codeclimate.com/github/dmpetrov/dataversioncontrol/coverage)
# Data version control
Git for data science projects. It streamlines your git code, your data (S3 and GCP) and the dependencies to a single reproducible environment.
Git for data science projects. It streamlines your git code, your data (S3 and GCP) and dependencies to a single reproducible environment.
# Tutorial
## Install
Please download dvc from http://dataversioncontrol.com for your platform or
install through source code from Github https://github.com/dmpetrov/dataversioncontrol.
## Initialize dvc environment
DVC is an extension of the git command.
It works on top of git command in existing git repositories.
Let's create a new git repository for the tutorial perpose.
```
$ cd ~/src
$ mkdir tag_classifier
$ cd tag_classifier
$ git init
Initialized empty Git repository in /Users/dmitry/src/tag_classifier/.git/
```
Initialize dvc environment:
```
$ dvc init
Info. Directories data/, cache/ and state/ were created
Info. File .gitignore was created
Info. Directory cache was added to .gitignore file
Info. [Git] A new commit a5ed2b9 was made in the current branch. Added files:
Info. [Git] A .gitignore
Info. [Git] A dvc.conf
```
The output shows the actions that dvc has made.
Special directories `data/`, `cache/` and `state/` were created to keep track of your external data which are not stored in git but have to be under users control.
These three directories are mondatory for any dvc project.
Names of the directory might be changed by the `dvc init` command line parameters `--data-dir`, `--cache-dir` and `--state-dir` respectively.
All these special directories names are going to be saved in `dvc.conf` config file as a result of
the init command and should not be changed over dvc project life time.
As you might see from the output `.gitignore` file was created (or modified if you already had one).
This is result of including cache directory to this file and also `.dvc.conf.lock` file.
Meaning of these changes will be explain later in the tutorial.
DVC commits all the chenges to the git repository at the end of successful run.
The commit step might be skipped by command line parameter `--skip-git-actions`.
This parameter might be applied to any dvc command.
## Importing data files
Data file has to be imported in dvc `data/` directory before using it.
```
$ dvc data-import https://s3-us-west-2.amazonaws.com/dataversioncontrol/functests/stackoverflow_raw_small/Badges.xml data/
$ dvc data-import https://s3-us-west-2.amazonaws.com/dvc-doc/small/Badges.xml data/
Info. [Git] A new commit 9f27943 was made in the current branch. Added files:
Info. [Git] A data/Badges.xml
Info. [Git] A state/Badges.xml.state
```
The data file was downloaded and now it is available in the data directory.
However, it is only a part of the work that dvc has done.
If you look carefully at the file you could see that this is actually a symlink to
a cache directory file Badges.xml_a5ed2b9:
```
$ ls -l data/Badges.xml
lrwxr-xr-x 1 dmitry staff 27B Apr 17 16:19 data/Badges.xml@ -> ../cache/Badges.xml_a5ed2b9
```
Instead of importing the file to `data/Badges.xml` the system imports the file in the cache directory
`cache/Badges.xml_a5ed2b9` and creates a symlink `data/Badges.xml`.
The file in the cache directory `cache/Badges.xml_a5ed2b9` has the same name as
the file in the data directory but extended by a suffix `_a5ed2b9` which
correspondes to a git snapshot checksum on the time when the file was created.
The system transparently moves the actual file to the cache directory and creates a symlink
1. `data/` directory was created to keep all your data files that not suppose to go to Git repository.
This is the directory users suppose to use for all the data files.
However, dvc transparently moves all actual files to the `cache` directory and keeps only
symlink in `data/`.
2. `cache/` directory is a directory the file content.
# Copyright
@@ -10,14 +102,65 @@ This project is distributed under the Apache license version 2.0 (see the LICENS
By submitting a pull request for this project, you agree to license your contribution under the Apache license version 2.0 to this project.
# Setup / Configuration
`dvc` supports both aws and google cloud; you will need accounts either from [aws](https://aws.amazon.com/) or [gcloud](https://cloud.google.com/).
Edit `dvc.conf`, and fill in `StoragePath` under `[Data]` with your preferred bucket and path. For example,
```
[Data]
StoragePath = gc://dvc-demo/dvcdata
```
## Amazon AWS Setup
NB: To see your available buckets, run `aws s3 ls`
Configuration:
* edit `dvc.conf`, and set `Cloud = amazon`
* under `[AWS]`, fill in `AccessKeyId` and `SecretAccessKey`
Test `dvc` is correctly configured by running `dvc test-aws` (TODO)
## Google Cloud Setup
NB: To see your available buckets, run `gsutil ls`.
Configuration:
* edit `dvc.conf`, and set `Cloud = google`
* under `[GC]`, set `ProjectName` to your preferred (or just your default) project name.
Test `dvc` is correctly configured by running `dvc test-gcloud`.
# Usage
```
mkdir t
cd !$
git init .
dvc init
Info. Directories data/, cache/ and state/ were created
Info. File .gitignore was created
Info. Directory cache was added to .gitignore file
Info. [Git] A new commit 34687b2 was made in the current branch. Added files:
Info. [Git] A .gitignore
Info. [Git] A dvc.conf
```
# Development
Create a virtualenv, for example, at `~/.environments/dvc` by `mkdir -p ~/.environments/dvc; virtualenv ~/.environments/dvc` or use `virtualenvwrapper`.
```
# if you use virtualenvwrapper
workon dvc
# otherwise:
source ~/.environments/dvc/bin/activate
pip install -r requirements.txt
# happy coding!
```
# Building
+12 -3
View File
@@ -2,7 +2,7 @@
DataDir = data
CacheDir = cache
StateDir = state
Cloud = AWS
Cloud = google
# Log levels: Debug, Info, Warning and Error
LogLevel = Info
@@ -11,7 +11,7 @@ LogLevel = Info
AccessKeyId =
SecretAccessKey =
StoragePath = dvc/tutorial
StoragePath =
Region = us-east-1
@@ -29,4 +29,13 @@ Storage = my-100gb-drive-io
KeyDir = ~/.ssh
KeyName = dvc-key
SecurityGroup = dvc-group
SecurityGroup = dvc-group
[GC]
ProjectName =
StoragePath =
[Data]
StoragePath =
+2 -3
View File
@@ -13,7 +13,6 @@ class CmdBase(object):
parser = argparse.ArgumentParser()
self.define_args(parser)
self._parsed_args, self._command_args = parser.parse_known_args(args=self.args)
pass
@property
def settings(self):
@@ -35,8 +34,8 @@ class CmdBase(object):
def config(self):
return self._settings.config
def cache_file_aws_key(self, file):
return '{}/{}'.format(self.config.aws_storage_prefix, file).strip('/')
def cache_file_key(self, file):
return '{}/{}'.format(self.config.storage_prefix, file).strip('/')
@property
def dvc_home(self):
+102 -33
View File
@@ -1,7 +1,9 @@
import base64
import hashlib
import os
from boto.s3.connection import S3Connection
from google.cloud import storage as gc
from dvc.command.base import CmdBase
from dvc.logger import Logger
@@ -28,33 +30,24 @@ def percent_cb(complete, total):
def file_md5(fname):
""" get the (md5 hexdigest, md5 digest) of a file """
hash_md5 = hashlib.md5()
with open(fname, "rb") as f:
for chunk in iter(lambda: f.read(1024*1000), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
return (hash_md5.hexdigest(), hash_md5.digest())
class CmdDataSync(CmdBase):
def __init__(self, settings):
super(CmdDataSync, self).__init__(settings)
conn = S3Connection(self.config.aws_access_key_id, self.config.aws_secret_access_key)
bucket_name = self.config.aws_storage_bucket
self._bucket = conn.lookup(bucket_name)
if not self._bucket:
self._bucket = conn.create_bucket(bucket_name)
Logger.info('S3 bucket "{}" was created'.format(bucket_name))
pass
def define_args(self, parser):
self.add_string_arg(parser, 'target', 'Target to sync - file or directory')
pass
def run(self):
if System.islink(self.parsed_args.target):
data_item = self.path_factory.existing_data_item(self.parsed_args.target)
data_item = self.settings.path_factory.data_item(self.parsed_args.target)
return self.sync_symlink(data_item)
if os.path.isdir(self.parsed_args.target):
@@ -68,50 +61,126 @@ class CmdDataSync(CmdBase):
if os.path.isdir(fname):
self.sync_dir(fname)
elif System.islink(fname):
self.sync_symlink(self.path_factory.existing_data_item(fname))
self.sync_symlink(self.settings.path_factory.data_item(fname))
else:
raise DataSyncError('Unsupported file type "{}"'.format(fname))
pass
def sync_symlink(self, data_item):
if os.path.isfile(data_item.cache.relative):
if os.path.isfile(data_item.resolved_cache.dvc):
self.sync_to_cloud(data_item)
else:
self.sync_from_cloud(data_item)
pass
def sync_from_cloud(self, item):
aws_key = self.cache_file_aws_key(item.cache.dvc)
key = self._bucket.get_key(aws_key)
def _get_bucket_aws(self):
""" get a bucket object, aws """
conn = S3Connection(self.config.aws_access_key_id, self.config.aws_secret_access_key, host=self.config.aws_region_host)
bucket_name = self.config.storage_bucket
bucket = conn.lookup(bucket_name)
if bucket is None:
raise DataSyncError('Bucket "{}" can\'t be accessed'.format(bucket_name))
return bucket
def _sync_from_cloud_aws(self, item):
""" sync from cloud, aws version """
bucket = self._get_bucket_aws()
key_name = self.cache_file_key(item.resolved_cache.dvc)
key = bucket.get_key(key_name)
if not key:
raise DataSyncError('File "{}" does not exist in the cloud'.format(aws_key))
raise DataSyncError('File "{}" does not exist in the cloud'.format(key_name))
Logger.info('Downloading cache file from S3 "{}/{}"'.format(self._bucket.name,
aws_key))
key.get_contents_to_filename(item.cache.relative, cb=percent_cb)
Logger.info('Downloading cache file from S3 "{}/{}"'.format(bucket.name, key_name))
key.get_contents_to_filename(item.resolved_cache.relative, cb=percent_cb)
Logger.info('Downloading completed')
pass
def sync_to_cloud(self, data_item):
aws_key = self.cache_file_aws_key(data_item.cache.dvc)
key = self._bucket.get_key(aws_key)
def _sync_to_cloud_aws(self, data_item):
""" sync_to_cloud, aws version """
aws_key = self.cache_file_key(data_item.resolved_cache.dvc)
bucket = self._get_bucket_aws()
key = bucket.get_key(aws_key)
if key:
Logger.debug('File already uploaded to the cloud. Checksum validation...')
md5_cloud = key.etag[1:-1]
md5_local = file_md5(data_item.cache.relative)
md5_local = file_md5(data_item.resolved_cache.dvc)[0]
if md5_cloud == md5_local:
Logger.debug('File checksum matches. No uploading is needed.')
return
Logger.debug('Checksum miss-match. Re-uploading is required.')
Logger.info('Uploading cache file "{}" to S3 "{}"'.format(data_item.cache.relative,
aws_key))
key = self._bucket.new_key(aws_key)
key.set_contents_from_filename(data_item.cache.relative, cb=percent_cb)
Logger.info('Uploading cache file "{}" to S3 "{}"'.format(data_item.resolved_cache.relative, aws_key))
key = bucket.new_key(aws_key)
key.set_contents_from_filename(data_item.resolved_cache.relative, cb=percent_cb)
Logger.info('Uploading completed')
pass
def _get_bucket_gc(self):
""" get a bucket object, gc """
client = gc.Client(project=self.config.gc_project_name)
bucket = client.bucket(self.config.storage_bucket)
if not bucket.exists():
raise DataSyncError('sync up: google cloud bucket {} doesn\'t exist'.format(self.config.storage_bucket))
return bucket
def _sync_from_cloud_gcp(self, item):
""" sync from cloud, gcp version
Params:
TODO
"""
bucket = self._get_bucket_gc()
key = self.cache_file_key(item.resolved_cache.dvc)
blob = bucket.get_blob(key)
if not blob:
raise DataSyncError('File "{}" does not exist in the cloud'.format(key))
Logger.info('Downloading cache file from gc "{}/{}"'.format(bucket.name, key))
blob.download_to_filename(item.resolved_cache.dvc)
Logger.info('Downloading completed')
def _sync_to_cloud_gcp(self, data_item):
""" sync_to_cloud, gcp version """
bucket = self._get_bucket_gc()
blob_name = self.cache_file_key(data_item.resolved_cache.dvc)
blob = bucket.get_blob(blob_name)
if blob is not None and blob.exists():
b64_encoded_md5 = base64.b64encode(file_md5(data_item.resolved_cache.dvc)[1])
if blob.md5_hash == b64_encoded_md5:
Logger.debug('checksum %s matches. Skipping upload' % data_item.cache.relative)
return
Logger.debug('checksum %s mismatch. re-uploading' % data_item.cache.relative)
Logger.info('uploading cache file "{} to gc "{}"'.format(data_item.cache.relative, blob_name))
blob = bucket.blob(blob_name)
blob.upload_from_filename(data_item.resolved_cache.relative)
Logger.info('uploading %s completed' % data_item.resolved_cache.relative)
def sync_from_cloud(self, item):
cloud = self.settings.config.get_cloud
assert cloud in ['amazon', 'google'], 'unknown cloud %s' % cloud
if self.settings.config.get_cloud == 'amazon':
return self._sync_from_cloud_aws(item)
elif self.settings.config.get_cloud == 'google':
return self._sync_from_cloud_gcp(item)
def sync_to_cloud(self, data_item):
cloud = self.settings.config.get_cloud
assert cloud in ['amazon', 'google'], 'unknown cloud %s' % cloud
if self.settings.config.get_cloud == 'amazon':
return self._sync_to_cloud_aws(data_item)
elif self.settings.config.get_cloud == 'google':
return self._sync_to_cloud_gcp(data_item)
if __name__ == '__main__':
+9 -2
View File
@@ -18,7 +18,7 @@ class CmdInit(CmdBase):
DataDir = {}
CacheDir = {}
StateDir = {}
Cloud = AWS
Cloud =
# Log levels: Debug, Info, Warning and Error
LogLevel = Info
@@ -45,7 +45,14 @@ Storage = my-100gb-drive-io
KeyDir = ~/.ssh
KeyName = dvc-key
SecurityGroup = dvc-group'''
SecurityGroup = dvc-group
[GC]
ProjectName =
[Data]
StoragePath =
'''
def __init__(self, settings):
super(CmdInit, self).__init__(settings)
+58
View File
@@ -0,0 +1,58 @@
import os
import sys
from pathlib import Path
from google.cloud import storage
from dvc.command.base import CmdBase
from dvc.logger import Logger
from dvc.config import Config
from dvc.runtime import Runtime
from dvc.settings import SettingsError
class CmdTest(CmdBase):
def __init__(self, settings):
super(CmdTest, self).__init__(settings)
def define_args(self, parser):
self.set_skip_git_actions(parser)
def run(self):
cloud = self.settings._config.get_cloud
assert cloud in ['amazon', 'google'], 'unknown cloud: %s' % cloud
if cloud == 'amazon':
print 'TODO'
sys.exit(-1)
elif cloud == 'google':
Logger.info('testing gcloud')
if self.settings._config.gc_project_name == '':
Logger.error('Please specify the google cloud project name in dvc.conf')
raise SettingsError('must specify GC ProjectName')
try:
client = storage.Client(project=self.settings._config.gc_project_name)
except:
Logger.error('initializing storage.Client raised')
raise
sb = self.config.storage_bucket
if sb == '':
Logger.error('Please specificy a bucket for google cloud in dvc.conf')
sys.exit(-1)
bucket = client.bucket(sb)
if not bucket.exists():
Logger.error('google cloud storage: bucket %s doesn\'t exist' % sb)
sys.exit(-1)
Logger.info('bucket %s visible: google cloud seems to be configured correctly' % sb)
sys.exit(0)
if __name__ == '__main__':
Runtime.run(CmdTest, False)
+51 -9
View File
@@ -30,7 +30,7 @@ class ConfigI(object):
return self._state_dir
@property
def aws_storage_prefix(self):
def storage_prefix(self):
return ''
@@ -66,19 +66,61 @@ class Config(ConfigI):
return self._config['AWS']['SecretAccessKey']
@property
def aws_storage_path(self):
return self._config['AWS']['StoragePath']
def aws_region_host(self):
""" get the region host needed for s3 access
See notes http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
"""
region = self._config['AWS']['Region']
if region is None or region == '':
return 's3.amazonaws.com'
if region == 'us-east-1':
return 's3.amazonaws.com'
return 's3.%s.amazonaws.com' % region
def _aws_storage_path_parts(self):
return self.aws_storage_path.strip('/').split('/', 1)
@property
def aws_storage_bucket(self):
return self._aws_storage_path_parts()[0]
def storage_path(self):
""" get storage path
Precedence: Storage, then cloud specific
"""
path = self._config['Data'].get('StoragePath', None)
if path:
return path
cloud = self.get_cloud
assert cloud in ['amazon', 'google'], 'unknown cloud %s' % cloud
if cloud == 'amazon':
path = self._config['AWS'].get('StoragePath', None)
elif cloud == 'google':
path = self._config['GC'].get('StoragePath', None)
if path is None:
raise ConfigError('invalid StoragePath: not set for Data or cloud specific')
return path
def _storage_path_parts(self):
return self.storage_path.strip('/').split('/', 1)
@property
def aws_storage_prefix(self):
parts = self._aws_storage_path_parts()
def storage_bucket(self):
""" Data -> StoragePath takes precedence; if doesn't exist, use cloud-specific """
return self._storage_path_parts()[0]
@property
def storage_prefix(self):
parts = self._storage_path_parts()
if len(parts) > 1:
return parts[1]
return ''
@property
def gc_project_name(self):
return self._config['GC']['ProjectName']
@property
def get_cloud(self):
""" get cloud choice: currently one of ['amazon', 'google'] """
return self._config['Global']['Cloud']
+6
View File
@@ -86,6 +86,12 @@ class DataItem(object):
cache_file = os.path.join(cache_dir, file_name)
return Path(cache_file, self._git)
@cached_property
def resolved_cache(self):
resolved_cache = os.path.realpath(self._data.relative)
return Path(resolved_cache, self._git)
@cached_property
def data_dir_abs(self):
return os.path.join(self._git.git_dir_abs, self._config.data_dir)
+20 -4
View File
@@ -10,11 +10,13 @@ import sys
from dvc.runtime import Runtime
from dvc.command.init import CmdInit
from dvc.command.data_import import CmdDataImport
from dvc.command.remove import CmdDataRemove
from dvc.command.run import CmdRun
from dvc.command.repro import CmdRepro
from dvc.command.data_sync import CmdDataSync
from dvc.command.data_import import CmdDataImport
from dvc.command.test import CmdTest
def print_usage():
@@ -30,18 +32,27 @@ def print_usage():
' data sync TODO',
' data remove TODO',
' data import TODO',
'',
'test credentials',
' test aws TODO',
' test gcloud test gcloud and credentials are setup correctly',
'')
print('\n'.join(usage))
if __name__ == '__main__':
cmds = ['init', 'run', 'sync', 'repro', 'data', 'data-sync', 'data-remove', 'data-import', 'cloud', \
'cloud', 'cloud-run', 'cloud-instance-create', 'cloud-instance-remove', 'cloud-instance-describe']
cmds = ['init', 'run', 'sync', 'data', 'data-sync', 'data-remove', 'data-import', 'cloud', \
'cloud', 'cloud-run', 'cloud-instance-create', 'cloud-instance-remove', 'cloud-instance-describe', \
'test', 'test-aws', 'test-gcloud', 'test-cloud']
cmds_expand = {'data': ['sync', 'remove', 'import'],
'cloud': ['run', 'instance-create', 'instance-remove', 'instance-describe']
'cloud': ['run', 'instance-create', 'instance-remove', 'instance-describe'],
'test': ['aws', 'cloud', 'gcloud'],
}
print('sys.argv: (%d) %s' % (len(sys.argv), sys.argv))
if len(sys.argv) < 2 or sys.argv[1] not in cmds:
print('Unimplemented or unrecognized command. ' + ' '.join(sys.argv[1]))
if len(sys.argv) >= 2:
print('Unimplemented or unrecognized command: ' + ' '.join(sys.argv[1:]))
print_usage()
sys.exit(-1)
@@ -76,6 +87,11 @@ if __name__ == '__main__':
print('cloud-instance-remove unimplemented')
elif cmd == 'cloud-instance-describe' or (cmd == 'cloud' and subcmd == 'instance-describe'):
print('cloud-instance-describe unimplemented')
elif cmd == 'test-aws' or (cmd == 'test' and subcmd == 'aws'):
print('TODO: test aws credentials')
elif cmd == 'test-gcloud' or (cmd=='test' and subcmd in ['cloud', 'gcloud']):
Runtime.run(CmdTest, args_start_loc=argv_offset)
else:
print('Unimplemented or unrecognized command. ' + ' '.join(sys.argv[1]))
print_usage()
+53
View File
@@ -3,28 +3,81 @@ appdirs==1.4.3
appnope==0.1.0
backports.shutil-get-terminal-size==1.0.0
boto==2.46.1
cachetools==2.0.0
configparser==3.5.0
decorator==4.0.11
dill==0.2.6
enum34==1.1.6
fasteners==0.14.1
future==0.16.0
futures==3.0.5
gapic-google-cloud-datastore-v1==0.15.3
gapic-google-cloud-error-reporting-v1beta1==0.15.3
gapic-google-cloud-logging-v2==0.91.3
gapic-google-cloud-pubsub-v1==0.15.3
gapic-google-cloud-spanner-admin-database-v1==0.15.3
gapic-google-cloud-spanner-admin-instance-v1==0.15.3
gapic-google-cloud-spanner-v1==0.15.3
gapic-google-cloud-speech-v1beta1==0.15.3
gapic-google-cloud-vision-v1==0.90.3
google-auth==0.10.0
google-auth-httplib2==0.0.2
google-cloud==0.24.0
google-cloud-bigquery==0.24.0
google-cloud-bigtable==0.24.0
google-cloud-core==0.24.0
google-cloud-datastore==1.0.0
google-cloud-dns==0.24.0
google-cloud-error-reporting==0.24.0
google-cloud-language==0.24.0
google-cloud-logging==0.24.0
google-cloud-monitoring==0.24.0
google-cloud-pubsub==0.24.0
google-cloud-resource-manager==0.24.0
google-cloud-runtimeconfig==0.24.0
google-cloud-spanner==0.24.0
google-cloud-speech==0.24.0
google-cloud-storage==1.0.0
google-cloud-translate==0.24.0
google-cloud-vision==0.24.0
google-gax==0.15.8
googleapis-common-protos==1.5.2
grpc-google-iam-v1==0.11.1
grpcio==1.2.1
httplib2==0.10.3
ipython==5.3.0
ipython-genutils==0.2.0
macholib==1.8
modulegraph==0.14
monotonic==1.3
nose==1.3.7
oauth2client==3.0.0
packaging==16.8
pathlib==1.0.1
pathlib2==2.2.1
pexpect==4.2.1
pickleshare==0.7.4
ply==3.8
prompt-toolkit==1.0.14
proto-google-cloud-datastore-v1==0.90.3
proto-google-cloud-error-reporting-v1beta1==0.15.3
proto-google-cloud-logging-v2==0.91.3
proto-google-cloud-pubsub-v1==0.15.3
proto-google-cloud-spanner-admin-database-v1==0.15.3
proto-google-cloud-spanner-admin-instance-v1==0.15.3
proto-google-cloud-spanner-v1==0.15.3
proto-google-cloud-speech-v1beta1==0.15.3
proto-google-cloud-vision-v1==0.90.3
protobuf==3.2.0
ptyprocess==0.5.1
py2app==0.12
pyasn1==0.2.3
pyasn1-modules==0.0.8
Pygments==2.2.0
PyInstaller==3.2.1
pyparsing==2.2.0
requests==2.13.0
rsa==3.4.2
scandir==1.5
simplegeneric==0.8.1
six==1.10.0