Repository: mlflow/mlflow

Issue:
[BUG] `FTPArtifactRepository.log_artifacts` does not work with `artifact_path` keyword argument.
### Willingness to contribute
The MLflow Community encourages bug fix contributions. Would you or another member of your organization be willing to contribute a fix for this bug to the MLflow code base?

- [ ] Yes. I can contribute a fix for this bug independently.
- [x] Yes. I would be willing to contribute a fix for this bug with guidance from the MLflow community.
- [ ] No. I cannot contribute a bug fix at this time.

### System information
- **Have I written custom code (as opposed to using a stock example script provided in MLflow)**: Yes
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**: Ubuntu 18.04
- **MLflow installed from (source or binary)**: both of via pip and pip+git
- **MLflow version (run ``mlflow --version``)**: 1.11.0
- **Python version**: 3.8.0

### Describe the problem

https://github.com/mlflow/mlflow/blob/d743a40426d5dedbde395a4e6bbdeebadbccd4dc/mlflow/store/artifact/ftp_artifact_repo.py#L75-L89

`artifact_path` will not be used if `root != local_dir` in `log_artifacts`.

Should I send a pull request? :

```diff
diff --git a/mlflow/store/artifact/ftp_artifact_repo.py b/mlflow/store/artifact/ftp_artifact_repo.py
index becb93d5..60103d9e 100644
--- a/mlflow/store/artifact/ftp_artifact_repo.py
+++ b/mlflow/store/artifact/ftp_artifact_repo.py
@@ -80,10 +80,11 @@ class FTPArtifactRepository(ArtifactRepository):
             upload_path = dest_path
             if root != local_dir:
                 rel_path = os.path.relpath(root, local_dir)
-                upload_path = relative_path_to_artifact_path(rel_path)
+                rel_upload_path = relative_path_to_artifact_path(rel_path)
+                upload_path = posixpath.join(dest_path, rel_upload_path)
             if not filenames:
                 with self.get_ftp_client() as ftp:
-                    self._mkdir(ftp, posixpath.join(self.path, upload_path))
+                    self._mkdir(ftp, upload_path)
             for f in filenames:
                 if os.path.isfile(os.path.join(root, f)):
                     self.log_artifact(os.path.join(root, f), upload_path)
```

### What component(s), interfaces, languages, and integrations does this bug affect?
Components 
- [x] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [ ] `area/projects`: MLproject format, project running backends
- [ ] `area/scoring`: Local serving, model deployment tools, spark UDFs
- [ ] `area/server-infra`: MLflow server, JavaScript dev server
- [ ] `area/tracking`: Tracking Service, tracking client APIs, autologging

Interface 
- [ ] `area/uiux`: Front-end, user experience, JavaScript, plotting
- [ ] `area/docker`: Docker use across MLflow's components, such as MLflow Projects and MLflow Models
- [ ] `area/sqlalchemy`: Use of SQLAlchemy in the Tracking Service or Model Registry
- [ ] `area/windows`: Windows support

Language 
- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

Integrations
- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations



Discussion:
@kzm4269 Thanks for repoting this. It looks like this is a bug. The test for `FTPArtifactRepository.log_artifacts` doesn't seem to cover a case where a directory to log contains directories (please take a look at the code below). We'd be happy to review a PR.

https://github.com/mlflow/mlflow/blob/5cf346fec70d1a680915c4760a809386b1a402f2/tests/store/artifact/test_ftp_artifact_repo.py#L214-L239

Resolving patch:
diff --git a/mlflow/store/artifact/ftp_artifact_repo.py b/mlflow/store/artifact/ftp_artifact_repo.py
--- a/mlflow/store/artifact/ftp_artifact_repo.py
+++ b/mlflow/store/artifact/ftp_artifact_repo.py
@@ -80,10 +80,11 @@ def log_artifacts(self, local_dir, artifact_path=None):
             upload_path = dest_path
             if root != local_dir:
                 rel_path = os.path.relpath(root, local_dir)
-                upload_path = relative_path_to_artifact_path(rel_path)
+                rel_upload_path = relative_path_to_artifact_path(rel_path)
+                upload_path = posixpath.join(dest_path, rel_upload_path)
             if not filenames:
                 with self.get_ftp_client() as ftp:
-                    self._mkdir(ftp, posixpath.join(self.path, upload_path))
+                    self._mkdir(ftp, upload_path)
             for f in filenames:
                 if os.path.isfile(os.path.join(root, f)):
                     self.log_artifact(os.path.join(root, f), upload_path)


Test patch:
diff --git a/tests/store/artifact/test_ftp_artifact_repo.py b/tests/store/artifact/test_ftp_artifact_repo.py
--- a/tests/store/artifact/test_ftp_artifact_repo.py
+++ b/tests/store/artifact/test_ftp_artifact_repo.py
@@ -211,32 +211,93 @@ def test_log_artifact_multiple_calls(ftp_mock, tmpdir):
     assert ftp_mock.storbinary.call_args_list[0][0][0] == "STOR test2.txt"
 
 
+def __posixpath_parents(pathname, root):
+    parents = [posixpath.dirname(pathname)]
+    root = posixpath.normpath(root)
+    while parents[-1] != "/" and parents[-1] != root:
+        parents.append(posixpath.dirname(parents[-1]))
+    return parents
+
+
 @pytest.mark.parametrize("artifact_path", [None, "dir", "dir1/dir2"])
 def test_log_artifacts(artifact_path, ftp_mock, tmpdir):
-    repo = FTPArtifactRepository("ftp://test_ftp/some/path")
+    # Setup FTP mock.
+    dest_path_root = "/some/path"
+    repo = FTPArtifactRepository("ftp://test_ftp" + dest_path_root)
 
     repo.get_ftp_client = MagicMock()
     call_mock = MagicMock(return_value=ftp_mock)
     repo.get_ftp_client.return_value = MagicMock(__enter__=call_mock)
 
+    dirs_created = set([dest_path_root])
+    files_created = set()
+    cwd_history = ["/"]
+
+    def mkd_mock(pathname):
+        abs_pathname = posixpath.join(cwd_history[-1], pathname)
+        if posixpath.dirname(abs_pathname) not in dirs_created:
+            raise ftplib.error_perm
+        dirs_created.add(abs_pathname)
+
+    ftp_mock.mkd = MagicMock(side_effect=mkd_mock)
+
+    def cwd_mock(pathname):
+        abs_pathname = posixpath.join(cwd_history[-1], pathname)
+        if abs_pathname not in dirs_created:
+            raise ftplib.error_perm
+        cwd_history.append(abs_pathname)
+
+    ftp_mock.cwd = MagicMock(side_effect=cwd_mock)
+
+    def storbinary_mock(cmd, _):
+        head, basename = cmd.split(" ", 1)
+        assert head == "STOR"
+        assert "/" not in basename
+        files_created.add(posixpath.join(cwd_history[-1], basename))
+
+    ftp_mock.storbinary = MagicMock(side_effect=storbinary_mock)
+
+    # Test
     subd = tmpdir.mkdir("data").mkdir("subdir")
     subd.join("a.txt").write("A")
     subd.join("b.txt").write("B")
     subd.join("c.txt").write("C")
+    subd.mkdir("empty1")
+    subsubd = subd.mkdir("subsubdir")
+    subsubd.join("aa.txt").write("AA")
+    subsubd.join("bb.txt").write("BB")
+    subsubd.join("cc.txt").write("CC")
+    subsubd.mkdir("empty2")
+
+    dest_path = (
+        dest_path_root if artifact_path is None else posixpath.join(dest_path_root, artifact_path)
+    )
+    dirs_expected = set(
+        [
+            dest_path,
+            posixpath.join(dest_path, "empty1"),
+            posixpath.join(dest_path, "subsubdir"),
+            posixpath.join(dest_path, "subsubdir", "empty2"),
+        ]
+    )
+    files_expected = set(
+        [
+            posixpath.join(dest_path, "a.txt"),
+            posixpath.join(dest_path, "b.txt"),
+            posixpath.join(dest_path, "c.txt"),
+            posixpath.join(dest_path, "subsubdir/aa.txt"),
+            posixpath.join(dest_path, "subsubdir/bb.txt"),
+            posixpath.join(dest_path, "subsubdir/cc.txt"),
+        ]
+    )
 
-    ftp_mock.cwd = MagicMock(side_effect=[ftplib.error_perm, None, None, None, None, None])
+    for dirs_expected_i in dirs_expected.copy():
+        if dirs_expected_i != dest_path_root:
+            dirs_expected |= set(__posixpath_parents(dirs_expected_i, root=dest_path_root))
 
     repo.log_artifacts(subd.strpath, artifact_path)
-
-    arg_expected = (
-        "/some/path" if artifact_path is None else posixpath.join("/some/path", artifact_path)
-    )
-    ftp_mock.mkd.assert_any_call(arg_expected)
-    ftp_mock.cwd.assert_any_call(arg_expected)
-
-    assert ftp_mock.storbinary.call_count == 3
-    storbinary_call_args = sorted([ftp_mock.storbinary.call_args_list[i][0][0] for i in range(3)])
-    assert storbinary_call_args == ["STOR a.txt", "STOR b.txt", "STOR c.txt"]
+    assert dirs_created == dirs_expected
+    assert files_created == files_expected
 
 
 def test_download_artifacts_single(ftp_mock):