Repository: wandb/wandb

Issue:
There should probably be a message if you wandb pull on an empty bucket
Right now it looks like

```
  % wandb pull bottle-angle                                                                                                     !10664
Downloading: bottle-angle/bottle-angle
```

And then returns happily.  Maybe it should say "bottle-angle/bottle-angle is empty"



Discussion:

Resolving patch:
diff --git a/wandb/api.py b/wandb/api.py
--- a/wandb/api.py
+++ b/wandb/api.py
@@ -137,6 +137,18 @@ def parse_slug(self, slug, project=None, bucket=None):
             bucket = bucket or slug
         return (project, bucket)
 
+    @normalize_exceptions
+    def viewer(self):
+        query = gql('''
+        query Viewer{
+            viewer {
+                id
+                entity
+            }
+        }
+        ''')
+        return self.client.execute(query).get('viewer', {})
+
     @normalize_exceptions
     def list_projects(self, entity=None):
         """Lists projects in W&B scoped by entity.
diff --git a/wandb/cli.py b/wandb/cli.py
--- a/wandb/cli.py
+++ b/wandb/cli.py
@@ -1,8 +1,8 @@
 # -*- coding: utf-8 -*-
 
 import click, sys
-from wandb import Api, Error, Sync
-import random, time, os, re, netrc, logging, json, glob, io
+from wandb import Api, Error, Sync, __version__
+import random, time, os, re, netrc, logging, json, glob, io, stat
 from functools import wraps
 from click.utils import LazyFile
 from click.exceptions import BadParameter, ClickException
@@ -13,12 +13,21 @@
 def normalize(host):
     return host.split("/")[-1].split(":")[0]
 
-def logged_in(host):
+def logged_in(host, retry=True):
     """Check if our host is in .netrc"""
     try:
         conf = netrc.netrc()
         return conf.hosts[normalize(host)]
-    except:
+    except netrc.NetrcParseError as e:
+        #chmod 0600 which is a common mistake, we could do this in `write_netrc`...
+        os.chmod(os.path.expanduser('~/.netrc'), stat.S_IRUSR | stat.S_IWUSR)
+        if retry:
+            return logged_in(host, retry=False)
+        else:
+            click.secho("Unable to read ~/.netrc: "+e.message, fg="red")
+            return None
+    except IOError as e:
+        click.secho("Unable to read ~/.netrc", fg="red")
         return None
 
 def write_netrc(host, entity, key):
@@ -61,7 +70,6 @@ def get_command(self, ctx, cmd_name):
             project, bucket = api.parse_slug(cmd_name)
         except Error:
             return None
-
         sync = Sync(api, project=project, bucket=bucket)
         if sync.source_proc:
             files = sys.argv[2:]
@@ -71,6 +79,7 @@ def get_command(self, ctx, cmd_name):
             return None
 
 @click.command(cls=BucketGroup)
+@click.version_option(version=__version__)
 @click.pass_context
 def cli(ctx):
     """Weights & Biases
@@ -245,11 +254,13 @@ def push(ctx, bucket, project, description, entity, files):
 def pull(project, bucket, kind, entity):
     project, bucket = api.parse_slug(bucket, project=project)
 
+    urls = api.download_urls(project, bucket=bucket, entity=entity)
+    if len(urls) == 0:
+        raise ClickException("Bucket is empty")
     click.echo("Downloading: {project}/{bucket}".format(
         project=click.style(project, bold=True), bucket=bucket
     ))
 
-    urls = api.download_urls(project, bucket=bucket, entity=entity)
     for name in urls:
         if api.file_current(name, urls[name]['md5']):
             click.echo("File %s is up to date" % name)
@@ -278,7 +289,10 @@ def config():
 @cli.command(context_settings=CONTEXT, help="Login to Weights & Biases")
 @display_error
 def login():
-    key = click.prompt("{warning} Enter an api key from https://app.wandb.ai/profile to enable uploads".format(
+    code = click.launch("https://app.wandb.ai/profile")
+    if code != 0:
+        click.echo("You can find your API keys here: https://app.wandb.ai/profile")
+    key = click.prompt("{warning} Paste an API key from your profile".format(
             warning=click.style("Not authenticated!", fg="red")), default="")
     #TODO: get the default entity from the API
     host = api.config()['base_url']
@@ -296,12 +310,12 @@ def init(ctx):
     if logged_in(api.config('base_url')) is None:
         ctx.invoke(login)
 
-    entity = click.prompt("What username or org should we use?", default="models")
+    entity = click.prompt("What username or org should we use?", default=api.viewer().get('entity', 'models'))
     #TODO: handle the case of a missing entity
     result = ctx.invoke(projects, entity=entity)
 
     if len(result) == 0:
-        project = click.prompt("Enter a name for your first project.")
+        project = click.prompt("Enter a name for your first project")
         description = editor()
         api.create_project(project, entity=entity, description=description)
     else:
@@ -310,7 +324,7 @@ def init(ctx):
         project = inquirer.prompt([question])['project']
         #TODO: check with the server if the project exists
         if project == "Create New":
-            project = click.prompt("Enter a name for your new project.")
+            project = click.prompt("Enter a name for your new project")
             description = editor()
             api.create_project(project, entity=entity, description=description)
 


Test patch:
diff --git a/tests/api_mocks.py b/tests/api_mocks.py
--- a/tests/api_mocks.py
+++ b/tests/api_mocks.py
@@ -17,13 +17,14 @@ def _files():
         ]
     }
 
-def _project(name='test'):
+def _project(name='test', empty=False):
+    files = {'edges':[]} if empty else _files()
     return {
         'name': name,
         'description': 'Test model',
         'bucket': {
             'framework': 'keras',
-            'files': _files()
+            'files': files
         }
     }
 
@@ -62,6 +63,10 @@ def _mutate(key, json):
 def query_project():
     return _query('model', _project())
 
+@pytest.fixture
+def query_empty_project():
+    return _query('model', _project(empty=True))
+
 @pytest.fixture
 def query_projects():
     return _query('models', [_project("test_1"), _project("test_2"), _project("test_3")])
@@ -70,6 +75,10 @@ def query_projects():
 def query_buckets():
     return _query('buckets', [_bucket("default"), _bucket("test_1")])
 
+@pytest.fixture
+def query_viewer():
+    return _success_or_failure(payload={'viewer': {'entity': 'foo'}})
+
 @pytest.fixture
 def upload_url():
     def wrapper(mocker, status_code=200, headers={}):
diff --git a/tests/test_cli.py b/tests/test_cli.py
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,5 +1,5 @@
-import pytest, os, traceback
-from wandb import cli, Api
+import pytest, os, traceback, click
+from wandb import cli, Api, __version__
 from click.testing import CliRunner
 from .api_mocks import *
 import netrc, signal, time
@@ -8,12 +8,17 @@
 @pytest.fixture
 def runner(monkeypatch):
     monkeypatch.setattr(cli, 'api', Api(default_config={'project': 'test'}, load_config=False))
+    monkeypatch.setattr(click, 'launch', lambda x: 1)
     monkeypatch.setattr(inquirer, 'prompt', lambda x: {'project': 'test_model', 'files': ['weights.h5']})
     return CliRunner()
 
 @pytest.fixture
 def empty_netrc(monkeypatch):
-    monkeypatch.setattr(netrc, "netrc", lambda x: {'hosts': []})
+    class FakeNet(object):
+        @property
+        def hosts(self):
+            return {'api.wandb.ai': None}
+    monkeypatch.setattr(netrc, "netrc", lambda *args: FakeNet())
 
 @pytest.fixture
 def local_netrc(monkeypatch):
@@ -29,7 +34,12 @@ def test_help(runner):
     assert 'Weights & Biases' in result.output
     help_result = runner.invoke(cli.cli, ['--help'])
     assert help_result.exit_code == 0
-    assert '--help  Show this message and exit.' in help_result.output
+    assert 'Show this message and exit.' in help_result.output
+
+def test_version(runner):
+    result = runner.invoke(cli.cli, ["--version"])
+    assert result.exit_code == 0
+    assert __version__ in result.output
 
 def test_config(runner, request_mocker, query_project, monkeypatch):
     query_project(request_mocker)
@@ -112,6 +122,16 @@ def test_pull_custom_bucket(runner, request_mocker, query_project, download_url)
         assert result.exit_code == 0
         assert "Downloading: test/test" in result.output
 
+def test_pull_empty_bucket(runner, request_mocker, query_empty_project, download_url):
+    query_empty_project(request_mocker)
+    result = runner.invoke(cli.pull, ['test/test'])
+    
+    print(result.output)
+    print(result.exception)
+    print(traceback.print_tb(result.exc_info[2]))
+    assert result.exit_code == 1
+    assert "Bucket is empty" in result.output
+
 def test_projects(runner, request_mocker, query_projects):
     query_projects(request_mocker)
     result = runner.invoke(cli.projects)
@@ -174,10 +194,14 @@ def test_projects_error(runner, request_mocker, query_projects):
     print(result.output)
     assert "Error" in result.output
 
-def test_init_new_login(runner, empty_netrc, local_netrc, request_mocker, query_projects):
+def test_init_new_login(runner, empty_netrc, local_netrc, request_mocker, query_projects, query_viewer):
+    query_viewer(request_mocker)
     query_projects(request_mocker)
     with runner.isolated_filesystem():
         result = runner.invoke(cli.init, input="12345\nvanpelt")
+        print(result.output)
+        print(result.exception)
+        print(traceback.print_tb(result.exc_info[2]))
         assert result.exit_code == 0
         with open("netrc", "r") as f:
             generatedNetrc = f.read()
@@ -186,13 +210,15 @@ def test_init_new_login(runner, empty_netrc, local_netrc, request_mocker, query_
         assert "12345" in generatedNetrc
         assert "test_model" in generatedWandb
 
-def test_init_add_login(runner, empty_netrc, local_netrc, request_mocker, query_projects):
+def test_init_add_login(runner, empty_netrc, local_netrc, request_mocker, query_projects, query_viewer):
+    query_viewer(request_mocker)
     query_projects(request_mocker)
     with runner.isolated_filesystem():
         with open("netrc", "w") as f:
             f.write("previous config")
         result = runner.invoke(cli.init, input="12345\nvanpelt\n")
         print(result.output)
+        print(result.exception)
         print(traceback.print_tb(result.exc_info[2]))
         assert result.exit_code == 0
         with open("netrc", "r") as f:
@@ -202,7 +228,8 @@ def test_init_add_login(runner, empty_netrc, local_netrc, request_mocker, query_
         assert "12345" in generatedNetrc
         assert "previous config" in generatedNetrc
 
-def test_existing_login(runner, local_netrc, request_mocker, query_projects):
+def test_existing_login(runner, local_netrc, request_mocker, query_projects, query_viewer):
+    query_viewer(request_mocker)
     query_projects(request_mocker)
     with runner.isolated_filesystem():
         with open("netrc", "w") as f: