Repository: streamlit/streamlit

Issue:
On first use, ask nicely for email/metrics
When the user first runs a `streamlit foo` command on the CLI, we currently show a message asking for the user's email:

```
  Enter your email for access to our beta
```

and then show a welcome message:
```
  Welcome to Streamlit!

  Get started by typing:
  $ streamlit hello
```

This was fine for our beta, but for a public launch we should:
1) Ask more nicely for the email, explaining why we need it :)
2) Allow people to _not_ enter an email if they don't want to
3) Tell people we log high-level analytics, and let them know how to turn them off. (And as a follow up, we should make it easier to turn this on/off in the future)

## Proposal

Replace the first message with:
```
  👋 Welcome to Streamlit! [in green]

  If you are one of our development partners or are interested in
  getting personal technical support, please enter your email address
  below. Otherwise, you may leave the field blank.

  Email:
```

and the second with:
```

  Telemetry: as an open source project, we use collect summary
  statistics and metadata to understand how people are using Streamlit.

  If you'd like to opt out, add the following to ~/.streamlit/config.toml,
  creating that file if necessary:

  [browser]
  gatherUsageStats = false

```
(leave the first and last lines in this block blank, so it doesn't touch any text we print above or below it.)


Discussion:

Resolving patch:
diff --git a/lib/streamlit/credentials.py b/lib/streamlit/credentials.py
--- a/lib/streamlit/credentials.py
+++ b/lib/streamlit/credentials.py
@@ -14,26 +14,23 @@
 # limitations under the License.
 
 """Manage the user's Streamlit credentials."""
-from collections import namedtuple
-import hashlib
-import hmac
+
 import os
 import sys
 import textwrap
+from collections import namedtuple
 
 import click
-import base58
 import toml
 
-from streamlit.logger import get_logger
 from streamlit import util
+from streamlit.logger import get_logger
 
 LOGGER = get_logger(__name__)
 
 Activation = namedtuple('Activation', [
-    'code',  # str: the user's activation code, sent via email.
     'email',  # str : the user's email.
-    'is_valid',  # boolean : whether the code+email combination is valid.
+    'is_valid',  # boolean : whether the email is valid.
 ])
 
 # For python 2.7
@@ -43,6 +40,32 @@
     FileNotFoundError = IOError
 
 
+EMAIL_PROMPT = '''
+  👋 %(welcome)s
+  
+  If you are one of our development partners or are interested in
+  getting personal technical support, please enter your email address
+  below. Otherwise, you may leave the field blank.
+  
+  Email''' % {'welcome': click.style('Welcome to Streamlit!', fg='green')}
+
+TELEMETRY_TEXT = '''
+  Telemetry: as an open source project, we collect summary statistics 
+  and metadata to understand how people are using Streamlit.
+  
+  If you'd like to opt out, add the following to ~/.streamlit/config.toml,
+  creating that file if necessary:
+  
+  [browser]
+  gatherUsageStats = false
+'''
+
+INSTRUCTIONS_TEXT = '''
+  Get started by typing:
+  $ %(hello)s
+''' % {'hello': click.style('streamlit hello', bold=True)}
+
+
 class Credentials(object):
     """Credentials class."""
     _singleton = None
@@ -75,8 +98,7 @@ def load(self, auto_resolve=False):
         try:
             with open(self._conf_file, 'r') as f:
                 data = toml.load(f).get('general')
-            self.activation = _verify_code(
-                data['email'], getattr(data, 'code', None))
+            self.activation = _verify_email(data.get('email'))
         except FileNotFoundError:
             if auto_resolve:
                 return self.activate(show_instructions=not auto_resolve)
@@ -102,7 +124,7 @@ def check_activated(self, auto_resolve=False):
             _exit(str(e))
 
         if not self.activation.is_valid:
-            _exit('Activation code/email not valid.')
+            _exit('Activation email not valid.')
 
     @classmethod
     def reset(cls):
@@ -122,7 +144,6 @@ def save(self):
         """Save to toml file."""
         data = {
             'email': self.activation.email,
-            'code': self.activation.code,
         }
         with open(self._conf_file, 'w') as f:
             toml.dump({'general': data}, f)
@@ -149,90 +170,49 @@ def activate(self, show_instructions=True):
             activated = False
 
             while not activated:
-                code = None  #code = _get_data('Enter your invite code')
-                email = _get_data('Enter your email for access to our beta')
 
-                self.activation = _verify_code(email, code)
+                email = click.prompt(
+                    text=EMAIL_PROMPT,
+                    default='', show_default=False)
+
+                self.activation = _verify_email(email)
                 if self.activation.is_valid:
                     self.save()
-                    click.secho('')
-                    click.secho('  Welcome to Streamlit!', fg='green')
-                    click.secho('')
+                    click.secho(TELEMETRY_TEXT)
                     if show_instructions:
-                        click.secho('  Get started by typing:')
-                        click.secho('  $ ', nl=False)
-                        click.secho('streamlit hello', bold=True)
-                        click.secho('')
+                        click.secho(INSTRUCTIONS_TEXT)
                     activated = True
                 else:  # pragma: nocover
                     LOGGER.error('Please try again.')
 
 
-def _generate_code(secret, email):
-    """Generate code for activation.
+def _verify_email(email):
+    """Verify the user's email address.
 
-    This is here so streamlit developers can create activation codes if
-    needed that are not in the spreadsheet.
-    """
-    secret = secret.encode('utf-8')
-    email = email.encode('utf-8')
+    The email can either be an empty string (if the user chooses not to enter
+    it), or a string with a single '@' somewhere in it.
 
-    salt = hmac.new(secret, email, hashlib.sha512).hexdigest()[0:4]
-    salt_encoded = salt.encode('utf-8')
-    hash = hmac.new(salt_encoded, email, hashlib.md5).hexdigest()[0:4]
+    Parameters
+    ----------
+    email : str
 
-    code = base58.b58encode(salt + hash)
-    return code.decode('utf-8')
+    Returns
+    -------
+    Activation
+        An Activation object. Its 'is_valid' property will be True only if
+        the email was validated.
 
+    """
+    email = email.strip()
 
-def _verify_code(email, code):
-    """Verify activation code with email."""
-    # Bypass code verification for now.
-    if email.count('@') != 1:
+    if len(email) > 0 and email.count('@') != 1:
         LOGGER.error('That doesn\'t look like an email :(')
-        return Activation(None, None, None)
+        return Activation(None, False)
 
-    if code == None:
-        return Activation(None, email, True)
-
-    # Python2/3 Madness
-    email_encoded = email
-    code_encoded = code
-    if sys.version_info >= (3, 0):
-        email_encoded = email.encode('utf-8')
-    else:
-        code_encoded = code.encode('utf-8')  # pragma: nocover
-
-    try:
-        decoded = base58.b58decode(code_encoded)
-
-        salt, hash = decoded[0:4], decoded[4:8]
-
-        calculated_hash = hmac.new(salt, email_encoded,
-                                   hashlib.md5).hexdigest()
-
-        if hash.decode('utf-8') == calculated_hash[0:4]:
-            return Activation(code, email, True)
-        else:
-            return Activation(code, email, False)
-
-    except Exception as e:
-        LOGGER.error('Unable to verify code: %s', e)
-        return Activation(None, None, None)
+    return Activation(email, True)
 
 
 def _exit(message):  # pragma: nocover
     """Exit program with error."""
     LOGGER.error(message)
     sys.exit(-1)
-
-
-def _get_data(msg):
-    """Utility to get data from console."""
-    data = None
-    while not data:
-        if sys.version_info >= (3, 0):
-            data = input('%s: ' % msg)
-        else:  # pragma: nocover
-            data = raw_input('%s: ' % msg)  # noqa: F821
-    return data


Test patch:
diff --git a/lib/tests/streamlit/credentials_test.py b/lib/tests/streamlit/credentials_test.py
--- a/lib/tests/streamlit/credentials_test.py
+++ b/lib/tests/streamlit/credentials_test.py
@@ -18,18 +18,21 @@
 import sys
 import textwrap
 import unittest
-import pytest
 
-from mock import call, mock_open, patch, MagicMock
+import pytest
+from mock import MagicMock
+from mock import call
+from mock import mock_open
+from mock import patch
 
-from streamlit.credentials import Activation, Credentials, _generate_code, _verify_code, _get_data
+from streamlit.credentials import Activation
+from streamlit.credentials import Credentials
+from streamlit.credentials import _verify_email
 
-if sys.version_info >= (3, 0):
-    INPUT = 'streamlit.credentials.input'
-else:
-    INPUT = 'streamlit.credentials.raw_input'
+if sys.version_info < (3, 0):
     FileNotFoundError = IOError
 
+PROMPT = 'streamlit.credentials.click.prompt'
 
 mock_get_path = MagicMock(
     return_value='/mock/home/folder/.streamlit/credentials.toml')
@@ -76,21 +79,32 @@ def test_Credentials_load(self):
         """Test Credentials.load()."""
         data = textwrap.dedent('''
             [general]
-            code = "ARzVsqhSB5i"
             email = "user@domain.com"
         ''').strip()
         m = mock_open(read_data=data)
-        with patch('streamlit.credentials.open', m, create=True) as m:
+        with patch('streamlit.credentials.open', m, create=True):
             c = Credentials.get_current()
             c.load()
-            self.assertEqual(c.activation.email, 'user@domain.com')
-            self.assertEqual(c.activation.code, None)
+            self.assertEqual('user@domain.com', c.activation.email)
+
+    @patch('streamlit.credentials.util.get_streamlit_file_path', mock_get_path)
+    def test_Credentials_load_empty(self):
+        """Test Credentials.load() with empty email"""
+        data = textwrap.dedent('''
+            [general]
+            email = ""
+        ''').strip()
+        m = mock_open(read_data=data)
+        with patch('streamlit.credentials.open', m, create=True):
+            c = Credentials.get_current()
+            c.load()
+            self.assertEqual('', c.activation.email)
 
     @patch('streamlit.credentials.util.get_streamlit_file_path', mock_get_path)
     def test_Credentials_load_twice(self):
         """Test Credentials.load() called twice."""
         c = Credentials.get_current()
-        c.activation = Activation('some_email', 'some_code', True)
+        c.activation = Activation('some_email', True)
         with patch('streamlit.credentials.LOGGER') as p:
             c.load()
             p.error.assert_called_once_with(
@@ -132,7 +146,7 @@ def test_Credentials_load_permission_denied(self):
     def test_Credentials_check_activated_already_loaded(self):
         """Test Credentials.check_activated() already loaded."""
         c = Credentials.get_current()
-        c.activation = Activation('some_email', 'some_code', True)
+        c.activation = Activation('some_email', True)
         with patch('streamlit.credentials._exit') as p:
             c.check_activated()
             p.assert_not_called()
@@ -141,16 +155,16 @@ def test_Credentials_check_activated_already_loaded(self):
     def test_Credentials_check_activated_false(self):
         """Test Credentials.check_activated() not activated."""
         c = Credentials.get_current()
-        c.activation = Activation('some_email', 'some_code', False)
+        c.activation = Activation('some_email', False)
         with patch('streamlit.credentials._exit') as p:
             c.check_activated()
-            p.assert_called_once_with('Activation code/email not valid.')
+            p.assert_called_once_with('Activation email not valid.')
 
     @patch('streamlit.credentials.util.get_streamlit_file_path', mock_get_path)
     def test_Credentials_check_activated_error(self):
         """Test Credentials.check_activated() has an error."""
         c = Credentials.get_current()
-        c.activation = Activation('some_email', 'some_code', True)
+        c.activation = Activation('some_email', True)
         with patch.object(c, 'load', side_effect=Exception(
                 'Some error')), patch('streamlit.credentials._exit') as p:
             c.check_activated()
@@ -160,16 +174,14 @@ def test_Credentials_check_activated_error(self):
     def test_Credentials_save(self):
         """Test Credentials.save()."""
         c = Credentials.get_current()
-        c.activation = Activation('some_code', 'some_email', True)
+        c.activation = Activation('some_email', True)
         truth = textwrap.dedent('''
             [general]
             email = "some_email"
-            code = "some_code"
         ''').lstrip()
 
         truth2 = textwrap.dedent('''
             [general]
-            code = "some_code"
             email = "some_email"
         ''').lstrip()
 
@@ -185,7 +197,7 @@ def test_Credentials_save(self):
     def test_Credentials_activate_already_activated(self):
         """Test Credentials.activate() already activated."""
         c = Credentials.get_current()
-        c.activation = Activation('some_email', 'some_code', True)
+        c.activation = Activation('some_email', True)
         with patch('streamlit.credentials.LOGGER') as p:
             with pytest.raises(SystemExit):
                 c.activate()
@@ -197,7 +209,7 @@ def test_Credentials_activate_already_activated(self):
     def test_Credentials_activate_already_activated_not_valid(self):
         """Test Credentials.activate() already activated but not valid."""
         c = Credentials.get_current()
-        c.activation = Activation('some_email', 'some_code', False)
+        c.activation = Activation('some_email', False)
         with patch('streamlit.credentials.LOGGER') as p:
             with pytest.raises(SystemExit):
                 c.activate()
@@ -211,14 +223,14 @@ def test_Credentials_activate(self):
         """Test Credentials.activate()"""
         c = Credentials.get_current()
         c.activation = None
-        with patch.object(
-                c, 'load',
-                side_effect=RuntimeError('Some error')), patch.object(
-                    c, 'save') as s, patch(INPUT) as p:
-            p.side_effect = ['ARzVsqhSB5i', 'user@domain.com']
+
+        with patch.object(c, 'load', side_effect=RuntimeError('Some error')), \
+             patch.object(c, 'save') as patched_save, \
+             patch(PROMPT) as patched_prompt:
+
+            patched_prompt.side_effect = ['user@domain.com']
             c.activate()
-            s.assert_called_once()
-            self.assertEqual(c.activation.code, None)
+            patched_save.assert_called_once()
             self.assertEqual(c.activation.email, 'user@domain.com')
             self.assertEqual(c.activation.is_valid, True)
 
@@ -233,9 +245,9 @@ def test_Credentials_reset(self):
     @patch('streamlit.credentials.util.get_streamlit_file_path', mock_get_path)
     def test_Credentials_reset_error(self):
         """Test Credentials.reset() with error."""
-        with patch('streamlit.credentials.os.remove',
-                   side_effect=OSError('some error')), patch(
-                       'streamlit.credentials.LOGGER') as p:
+        with patch('streamlit.credentials.os.remove', side_effect=OSError('some error')), \
+             patch('streamlit.credentials.LOGGER') as p:
+
             Credentials.reset()
             p.error.assert_called_once_with(
                 'Error removing credentials file: some error')
@@ -244,32 +256,8 @@ def test_Credentials_reset_error(self):
 class CredentialsModulesTest(unittest.TestCase):
     """Credentials Module Unittest class."""
 
-    def test__generate_code(self):
-        """Test _generate_code."""
-        code = _generate_code('testing', 'user@domain.com')
-        self.assertEqual('ARzVsqhSB5i', code)
-
-    def test__verify_code(self):
-        """Test _generate_code."""
-        ret = _verify_code('user@domain.com', 'ARzVsqhSB5i')
-        self.assertTrue(ret.is_valid)
-
-    def test__verify_code_wrong_code(self):
-        """Test credentials._verify_code with code from another user."""
-        ret = _verify_code('user@domain.com', 'ARxJtdP43GU')
-        self.assertFalse(ret.is_valid)
-
-    def test__verify_code_bad_code(self):
-        """Test credentials._verify_code with invalid base58 code."""
-        ret = _verify_code('user@domain.com', '****')
-        self.assertFalse(ret.is_valid)
-
-    def test_get_data(self):
-        """Test get_data."""
-        with patch(INPUT) as p:
-            p.return_value = 'my data'
-            data = _get_data('some message')
-
-            self.assertEqual(1, p.call_count)
-            self.assertEqual('some message: ', p.call_args[0][0])
-            self.assertEqual('my data', data)
+    def test_verify_email(self):
+        """Test _verify_email."""
+        self.assertTrue(_verify_email('user@domain.com').is_valid)
+        self.assertTrue(_verify_email('').is_valid)
+        self.assertFalse(_verify_email('missing_at_sign').is_valid)