Repository: mlflow/mlflow

Issue:
log_param failes logging string containing \n
### System information
mlflow, version 1.4.0
Python 3.7.4

### Describe the problem
mlflow throws and exception: "Exception: Unexpected data for param <key>. Param recorded more than once" when trying to log a multiline string with log_param. 

Ok, this is a special case and I don't see many use-cases, but I stumbled this today. That said, I don't think the Exception description is not very clear (took me a while do understand what actually was wrong - I only tried to log the param once...).


### Code to reproduce issue
Provide a reproducible test case that is the bare minimum necessary to generate the problem.
```
import mlflow

# create a multiline string
a_two_line_str = "a\nb"

with mlflow.start_run() as mlrun:

    # this works
    clean_str = a_two_line_str.replace("\n|\r", "")
    mlflow.log_param("two_liner", clean_str)
    print("no exception yet")

    # this fails
    print(a_two_line_str)
    mlflow.log_param("two_liner", a_two_line_str)
    # >> Exception: Unexpected data for param 'two_liner'. Param recorded more than once

```

### Other info / logs
The reason for the exception is that `len(param_data)>1`, since `len("a\nb")>1`.

Propose either 1) a fix to strip for \n and \r, or 2) give a more describing Exception string, e.g:

#### 1) Strip \n and \r from param data strings
mlflow.tracking._tracking_service.client.TrackingServiceClient.log_param:
```
def log_param(self, run_id, key, value):
    """
    Log a parameter against the run ID. Value is converted to a string.
    """
    _validate_param_name(key)
    param = Param(key, str(value).replace("\n|\r", "")) #change: the '.replace(..)
    self.store.log_param(run_id, param)
```

#### 2) Change Exception description
mlflow.store.tracking.file_store.FileStore._get_param_from_file:
```
@staticmethod
def _get_param_from_file(parent_path, param_name):
    _validate_param_name(param_name)
    param_data = read_file_lines(parent_path, param_name)
    if len(param_data) > 1:
        raise Exception("Unexpected data for param '%s'. Len of param data is bigger than 1"
                        % param_name)
    # The only cause for param_data's length to be zero is the param's
    # value is an empty string
    value = '' if len(param_data) == 0 else str(param_data[0].strip())
    return Param(param_name, value)
```
 



Discussion:
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Thank you for interacting with this issue! Removing the stale label!

@reidarkind This appears to be an issue with the File Store. I'll address it!

Resolving patch:
diff --git a/mlflow/store/tracking/file_store.py b/mlflow/store/tracking/file_store.py
--- a/mlflow/store/tracking/file_store.py
+++ b/mlflow/store/tracking/file_store.py
@@ -515,13 +515,7 @@ def _get_metric_history(self, run_info, metric_key):
     @staticmethod
     def _get_param_from_file(parent_path, param_name):
         _validate_param_name(param_name)
-        param_data = read_file_lines(parent_path, param_name)
-        if len(param_data) > 1:
-            raise Exception("Unexpected data for param '%s'. Param recorded more than once"
-                            % param_name)
-        # The only cause for param_data's length to be zero is the param's
-        # value is an empty string
-        value = '' if len(param_data) == 0 else str(param_data[0].strip())
+        value = read_file(parent_path, param_name)
         return Param(param_name, value)
 
     def get_all_params(self, run_uuid):
@@ -643,8 +637,30 @@ def log_param(self, run_id, param):
 
     def _log_run_param(self, run_info, param):
         param_path = self._get_param_path(run_info.experiment_id, run_info.run_id, param.key)
+        writeable_param_value = self._writeable_value(param.value)
+        if os.path.exists(param_path):
+            self._validate_new_param_value(
+                param_path=param_path, param_key=param.key,
+                run_id=run_info.run_id, new_value=writeable_param_value)
         make_containing_dirs(param_path)
-        write_to(param_path, self._writeable_value(param.value))
+        write_to(param_path, writeable_param_value)
+
+    def _validate_new_param_value(self, param_path, param_key, run_id, new_value):
+        """
+        When logging a parameter with a key that already exists, this function is used to
+        enforce immutability by verifying that the specified parameter value matches the existing
+        value.
+        :raises: py:class:`mlflow.exceptions.MlflowException` if the specified new parameter value
+                 does not match the existing parameter value.
+        """
+        with open(param_path, "r") as param_file:
+            current_value = param_file.read()
+        if current_value != new_value:
+            raise MlflowException(
+                "Changing param values is not allowed. Param with key='{}' was already"
+                " logged with value='{}' for run ID='{}'. Attempted logging new value"
+                " '{}'.".format(param_key, current_value, run_id, new_value),
+                databricks_pb2.INVALID_PARAMETER_VALUE)
 
     def set_experiment_tag(self, experiment_id, tag):
         """
diff --git a/mlflow/store/tracking/sqlalchemy_store.py b/mlflow/store/tracking/sqlalchemy_store.py
--- a/mlflow/store/tracking/sqlalchemy_store.py
+++ b/mlflow/store/tracking/sqlalchemy_store.py
@@ -517,8 +517,8 @@ def log_param(self, run_id, param):
                 if len(existing_params) > 0:
                     old_value = existing_params[0]
                     raise MlflowException(
-                        "Changing param value is not allowed. Param with key='{}' was already"
-                        " logged with value='{}' for run ID='{}. Attempted logging new value"
+                        "Changing param values is not allowed. Param with key='{}' was already"
+                        " logged with value='{}' for run ID='{}'. Attempted logging new value"
                         " '{}'.".format(
                             param.key, old_value, run_id, param.value), INVALID_PARAMETER_VALUE)
                 else:


Test patch:
diff --git a/tests/store/tracking/test_file_store.py b/tests/store/tracking/test_file_store.py
--- a/tests/store/tracking/test_file_store.py
+++ b/tests/store/tracking/test_file_store.py
@@ -19,7 +19,9 @@
 from mlflow.store.tracking import SEARCH_MAX_RESULTS_DEFAULT
 from mlflow.store.tracking.file_store import FileStore
 from mlflow.utils.file_utils import write_yaml, read_yaml, path_to_local_file_uri, TempDir
-from mlflow.protos.databricks_pb2 import ErrorCode, RESOURCE_DOES_NOT_EXIST, INTERNAL_ERROR
+from mlflow.protos.databricks_pb2 import (
+    ErrorCode, RESOURCE_DOES_NOT_EXIST, INTERNAL_ERROR, INVALID_PARAMETER_VALUE
+)
 
 from tests.helper_functions import random_int, random_str, safe_edit_yaml
 
@@ -595,7 +597,7 @@ def test_weird_param_names(self):
         run = fs.get_run(run_id)
         assert run.data.params[WEIRD_PARAM_NAME] == "Value"
 
-    def test_log_empty_str(self):
+    def test_log_param_empty_str(self):
         PARAM_NAME = "new param"
         fs = FileStore(self.test_root)
         run_id = self.exp_data[FileStore.DEFAULT_EXPERIMENT_ID]["runs"][0]
@@ -603,6 +605,28 @@ def test_log_empty_str(self):
         run = fs.get_run(run_id)
         assert run.data.params[PARAM_NAME] == ""
 
+    def test_log_param_with_newline(self):
+        param_name = "new param"
+        param_value = "a string\nwith multiple\nlines"
+        fs = FileStore(self.test_root)
+        run_id = self.exp_data[FileStore.DEFAULT_EXPERIMENT_ID]["runs"][0]
+        fs.log_param(run_id, Param(param_name, param_value))
+        run = fs.get_run(run_id)
+        assert run.data.params[param_name] == param_value
+
+    def test_log_param_enforces_value_immutability(self):
+        param_name = "new param"
+        fs = FileStore(self.test_root)
+        run_id = self.exp_data[FileStore.DEFAULT_EXPERIMENT_ID]["runs"][0]
+        fs.log_param(run_id, Param(param_name, "value1"))
+        # Duplicate calls to `log_param` with the same key and value should succeed
+        fs.log_param(run_id, Param(param_name, "value1"))
+        with pytest.raises(MlflowException) as exc:
+            fs.log_param(run_id, Param(param_name, "value2"))
+        assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
+        run = fs.get_run(run_id)
+        assert run.data.params[param_name] == "value1"
+
     def test_weird_metric_names(self):
         WEIRD_METRIC_NAME = "this is/a weird/but valid metric"
         fs = FileStore(self.test_root)
diff --git a/tests/store/tracking/test_sqlalchemy_store.py b/tests/store/tracking/test_sqlalchemy_store.py
--- a/tests/store/tracking/test_sqlalchemy_store.py
+++ b/tests/store/tracking/test_sqlalchemy_store.py
@@ -619,7 +619,7 @@ def test_log_param_uniqueness(self):
 
         with self.assertRaises(MlflowException) as e:
             self.store.log_param(run.info.run_id, param2)
-        self.assertIn("Changing param value is not allowed. Param with key=", e.exception.message)
+        self.assertIn("Changing param values is not allowed. Param with key=", e.exception.message)
 
     def test_log_empty_str(self):
         run = self._run_factory()
@@ -1333,7 +1333,7 @@ def test_log_batch_param_overwrite_disallowed(self):
         with self.assertRaises(MlflowException) as e:
             self.store.log_batch(run.info.run_id, metrics=[metric], params=[overwrite_param],
                                  tags=[tag])
-        self.assertIn("Changing param value is not allowed. Param with key=", e.exception.message)
+        self.assertIn("Changing param values is not allowed. Param with key=", e.exception.message)
         assert e.exception.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
         self._verify_logged(run.info.run_id, metrics=[], params=[param], tags=[])
 
@@ -1348,7 +1348,7 @@ def test_log_batch_param_overwrite_disallowed_single_req(self):
         with self.assertRaises(MlflowException) as e:
             self.store.log_batch(run.info.run_id, metrics=[metric], params=[param0, param1],
                                  tags=[tag])
-        self.assertIn("Changing param value is not allowed. Param with key=", e.exception.message)
+        self.assertIn("Changing param values is not allowed. Param with key=", e.exception.message)
         assert e.exception.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE)
         self._verify_logged(run.info.run_id, metrics=[], params=[param0], tags=[])