Repository: streamlit/streamlit
Pull request: Email, metrics prompt
- Nicer `streamlit activate` prompt and text from @tvst
- Remove the unused "activation code" stuff from Credentials.
- Credentials: email is now optional. If the user doesn't enter one, it will be stored as the empty string. The client will omit the "authoremail" trait from the Segment.io identifier if the user didn't enter an email.

Fixes #63 

Problem statement:
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.)

Hints from issue discussion:

Commit message: Damn. Python 2.7 doesn't have textwrap.indent

Patch to review:
diff --git a/frontend/src/lib/MetricsManager.ts b/frontend/src/lib/MetricsManager.ts
index 4f599166eb42..7b05b36e62f4 100644
--- a/frontend/src/lib/MetricsManager.ts
+++ b/frontend/src/lib/MetricsManager.ts
@@ -34,8 +34,6 @@ interface DeltaCounter {
 
 type Event = [string, object]
 
-type SendFunction = (evName: string, evData: object) => void
-
 export class MetricsManager {
   private initialized = false
 
@@ -72,9 +70,12 @@ export class MetricsManager {
     this.actuallySendMetrics = gatherUsageStats
 
     if (this.actuallySendMetrics || IS_SHARED_REPORT) {
-      this.identify(SessionInfo.current.installationId, {
-        authoremail: SessionInfo.current.authorEmail,
-      })
+      // Only record the user's email if they entered a non-empty one.
+      const userTraits: any = {}
+      if (SessionInfo.current.authorEmail !== "") {
+        userTraits["authoremail"] = SessionInfo.current.authorEmail
+      }
+      this.identify(SessionInfo.current.installationId, userTraits)
       this.sendPendingEvents()
     }
 
diff --git a/lib/streamlit/credentials.py b/lib/streamlit/credentials.py
index e025d521c606..0fcd624fd37a 100644
--- 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 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
+'''
+
+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
diff --git a/lib/tests/streamlit/credentials_test.py b/lib/tests/streamlit/credentials_test.py
index 4fa2e197aa8e..80133559ca3a 100644
--- 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)


Code review comments:
diff_hunk: @@ -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 use collect summary
path: lib/streamlit/credentials.py
text: @user1:
we use collect?

@author:
Whoops - thanks