Repository: feast-dev/feast

Issue:
Add S3 support to FileSource
**Is your feature request related to a problem? Please describe.**
It should be possible to use s3 file path directly in ```FileSource```
eg.
``` python
driver_hourly_stats = FileSource(
    path="s3://driver/driver_stats.parquet",
    event_timestamp_column="datetime",
    created_timestamp_column="created",
)
```
additionally it should be possible to use custom S3 endpoints.

**Describe the solution you'd like**
I have added S3 support which is internally handled by pyarrow and pandas. 
It is also possible to use custom S3 endpoints using ```FEAST_S3_ENDPOINT_URL``` environment variable




Discussion:
Thanks for this @qooba. Also, thank you for being so patient with our delayed reviews. We're hoping to speed things up going forward!

Resolving patch:
diff --git a/sdk/python/feast/infra/offline_stores/file.py b/sdk/python/feast/infra/offline_stores/file.py
--- a/sdk/python/feast/infra/offline_stores/file.py
+++ b/sdk/python/feast/infra/offline_stores/file.py
@@ -112,7 +112,11 @@ def evaluate_historical_retrieval():
                 )
 
                 # Read offline parquet data in pyarrow format.
-                table = pyarrow.parquet.read_table(feature_view.batch_source.path)
+                filesystem, path = FileSource.create_filesystem_and_path(
+                    feature_view.batch_source.path,
+                    feature_view.batch_source.file_options.s3_endpoint_override,
+                )
+                table = pyarrow.parquet.read_table(path, filesystem=filesystem)
 
                 # Rename columns by the field mapping dictionary if it exists
                 if feature_view.batch_source.field_mapping is not None:
@@ -238,7 +242,10 @@ def pull_latest_from_table_or_query(
 
         # Create lazy function that is only called from the RetrievalJob object
         def evaluate_offline_job():
-            source_df = pd.read_parquet(data_source.path)
+            filesystem, path = FileSource.create_filesystem_and_path(
+                data_source.path, data_source.file_options.s3_endpoint_override
+            )
+            source_df = pd.read_parquet(path, filesystem=filesystem)
             # Make sure all timestamp fields are tz-aware. We default tz-naive fields to UTC
             source_df[event_timestamp_column] = source_df[event_timestamp_column].apply(
                 lambda x: x if x.tzinfo is not None else x.replace(tzinfo=pytz.utc)
diff --git a/sdk/python/feast/infra/offline_stores/file_source.py b/sdk/python/feast/infra/offline_stores/file_source.py
--- a/sdk/python/feast/infra/offline_stores/file_source.py
+++ b/sdk/python/feast/infra/offline_stores/file_source.py
@@ -1,5 +1,7 @@
 from typing import Callable, Dict, Iterable, Optional, Tuple
 
+from pyarrow._fs import FileSystem
+from pyarrow._s3fs import S3FileSystem
 from pyarrow.parquet import ParquetFile
 
 from feast import type_map
@@ -20,6 +22,7 @@ def __init__(
         created_timestamp_column: Optional[str] = "",
         field_mapping: Optional[Dict[str, str]] = None,
         date_partition_column: Optional[str] = "",
+        s3_endpoint_override: Optional[str] = None,
     ):
         """Create a FileSource from a file containing feature data. Only Parquet format supported.
 
@@ -33,6 +36,7 @@ def __init__(
             file_format (optional): Explicitly set the file format. Allows Feast to bypass inferring the file format.
             field_mapping: A dictionary mapping of column names in this data source to feature names in a feature table
                 or view. Only used for feature columns, not entities or timestamp columns.
+            s3_endpoint_override (optional): Overrides AWS S3 enpoint with custom S3 storage
 
         Examples:
             >>> from feast import FileSource
@@ -51,7 +55,11 @@ def __init__(
         else:
             file_url = path
 
-        self._file_options = FileOptions(file_format=file_format, file_url=file_url)
+        self._file_options = FileOptions(
+            file_format=file_format,
+            file_url=file_url,
+            s3_endpoint_override=s3_endpoint_override,
+        )
 
         super().__init__(
             event_timestamp_column,
@@ -70,6 +78,8 @@ def __eq__(self, other):
             and self.event_timestamp_column == other.event_timestamp_column
             and self.created_timestamp_column == other.created_timestamp_column
             and self.field_mapping == other.field_mapping
+            and self.file_options.s3_endpoint_override
+            == other.file_options.s3_endpoint_override
         )
 
     @property
@@ -102,6 +112,7 @@ def from_proto(data_source: DataSourceProto):
             event_timestamp_column=data_source.event_timestamp_column,
             created_timestamp_column=data_source.created_timestamp_column,
             date_partition_column=data_source.date_partition_column,
+            s3_endpoint_override=data_source.file_options.s3_endpoint_override,
         )
 
     def to_proto(self) -> DataSourceProto:
@@ -128,9 +139,26 @@ def source_datatype_to_feast_value_type() -> Callable[[str], ValueType]:
     def get_table_column_names_and_types(
         self, config: RepoConfig
     ) -> Iterable[Tuple[str, str]]:
-        schema = ParquetFile(self.path).schema_arrow
+        filesystem, path = FileSource.create_filesystem_and_path(
+            self.path, self._file_options.s3_endpoint_override
+        )
+        schema = ParquetFile(
+            path if filesystem is None else filesystem.open_input_file(path)
+        ).schema_arrow
         return zip(schema.names, map(str, schema.types))
 
+    @staticmethod
+    def create_filesystem_and_path(
+        path: str, s3_endpoint_override: str
+    ) -> Tuple[Optional[FileSystem], str]:
+        if path.startswith("s3://"):
+            s3fs = S3FileSystem(
+                endpoint_override=s3_endpoint_override if s3_endpoint_override else None
+            )
+            return s3fs, path.replace("s3://", "")
+        else:
+            return None, path
+
 
 class FileOptions:
     """
@@ -138,10 +166,22 @@ class FileOptions:
     """
 
     def __init__(
-        self, file_format: Optional[FileFormat], file_url: Optional[str],
+        self,
+        file_format: Optional[FileFormat],
+        file_url: Optional[str],
+        s3_endpoint_override: Optional[str],
     ):
+        """
+        FileOptions initialization method
+
+        Args:
+            file_format (FileFormat, optional): file source format eg. parquet
+            file_url (str, optional): file source url eg. s3:// or local file
+            s3_endpoint_override (str, optional): custom s3 endpoint (used only with s3 file_url)
+        """
         self._file_format = file_format
         self._file_url = file_url
+        self._s3_endpoint_override = s3_endpoint_override
 
     @property
     def file_format(self):
@@ -171,6 +211,20 @@ def file_url(self, file_url):
         """
         self._file_url = file_url
 
+    @property
+    def s3_endpoint_override(self):
+        """
+        Returns the s3 endpoint override
+        """
+        return None if self._s3_endpoint_override == "" else self._s3_endpoint_override
+
+    @s3_endpoint_override.setter
+    def s3_endpoint_override(self, s3_endpoint_override):
+        """
+        Sets the s3 endpoint override
+        """
+        self._s3_endpoint_override = s3_endpoint_override
+
     @classmethod
     def from_proto(cls, file_options_proto: DataSourceProto.FileOptions):
         """
@@ -185,6 +239,7 @@ def from_proto(cls, file_options_proto: DataSourceProto.FileOptions):
         file_options = cls(
             file_format=FileFormat.from_proto(file_options_proto.file_format),
             file_url=file_options_proto.file_url,
+            s3_endpoint_override=file_options_proto.s3_endpoint_override,
         )
         return file_options
 
@@ -201,6 +256,7 @@ def to_proto(self) -> DataSourceProto.FileOptions:
                 None if self.file_format is None else self.file_format.to_proto()
             ),
             file_url=self.file_url,
+            s3_endpoint_override=self.s3_endpoint_override,
         )
 
         return file_options_proto
diff --git a/sdk/python/setup.py b/sdk/python/setup.py
--- a/sdk/python/setup.py
+++ b/sdk/python/setup.py
@@ -82,6 +82,7 @@
     "isort>=5",
     "grpcio-tools==1.34.0",
     "grpcio-testing==1.34.0",
+    "minio==7.1.0",
     "mock==2.0.0",
     "moto",
     "mypy==0.790",
@@ -99,6 +100,7 @@
     "pytest-mock==1.10.4",
     "Sphinx!=4.0.0",
     "sphinx-rtd-theme",
+    "testcontainers==3.4.2",
     "adlfs==0.5.9",
     "firebase-admin==4.5.2",
     "pre-commit",


Test patch:
diff --git a/sdk/python/tests/integration/feature_repos/test_repo_configuration.py b/sdk/python/tests/integration/feature_repos/test_repo_configuration.py
--- a/sdk/python/tests/integration/feature_repos/test_repo_configuration.py
+++ b/sdk/python/tests/integration/feature_repos/test_repo_configuration.py
@@ -115,7 +115,7 @@ def customer_feature_view(self) -> FeatureView:
             customer_table_id = self.data_source_creator.get_prefixed_table_name(
                 self.name, "customer_profile"
             )
-            ds = self.data_source_creator.create_data_sources(
+            ds = self.data_source_creator.create_data_source(
                 customer_table_id,
                 self.customer_df,
                 event_timestamp_column="event_timestamp",
@@ -129,7 +129,7 @@ def driver_stats_feature_view(self) -> FeatureView:
             driver_table_id = self.data_source_creator.get_prefixed_table_name(
                 self.name, "driver_hourly"
             )
-            ds = self.data_source_creator.create_data_sources(
+            ds = self.data_source_creator.create_data_source(
                 driver_table_id,
                 self.driver_df,
                 event_timestamp_column="event_timestamp",
@@ -145,7 +145,7 @@ def orders_table(self) -> Optional[str]:
             orders_table_id = self.data_source_creator.get_prefixed_table_name(
                 self.name, "orders"
             )
-            ds = self.data_source_creator.create_data_sources(
+            ds = self.data_source_creator.create_data_source(
                 orders_table_id,
                 self.orders_df,
                 event_timestamp_column="event_timestamp",
@@ -221,7 +221,7 @@ def construct_test_environment(
     offline_creator: DataSourceCreator = importer.get_class_from_type(
         module_name, config_class_name, "DataSourceCreator"
     )(project)
-    ds = offline_creator.create_data_sources(
+    ds = offline_creator.create_data_source(
         project, df, field_mapping={"ts_1": "ts", "id": "driver_id"}
     )
     offline_store = offline_creator.create_offline_store_config()
diff --git a/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py b/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py
--- a/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py
+++ b/sdk/python/tests/integration/feature_repos/universal/data_source_creator.py
@@ -9,7 +9,7 @@
 
 class DataSourceCreator(ABC):
     @abstractmethod
-    def create_data_sources(
+    def create_data_source(
         self,
         destination: str,
         df: pd.DataFrame,
diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py
--- a/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py
+++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/bigquery.py
@@ -40,7 +40,7 @@ def teardown(self):
     def create_offline_store_config(self):
         return BigQueryOfflineStoreConfig()
 
-    def create_data_sources(
+    def create_data_source(
         self,
         destination: str,
         df: pd.DataFrame,
diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py
--- a/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py
+++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/file.py
@@ -2,6 +2,9 @@
 from typing import Any, Dict
 
 import pandas as pd
+from minio import Minio
+from testcontainers.core.generic import DockerContainer
+from testcontainers.core.waiting_utils import wait_for_logs
 
 from feast import FileSource
 from feast.data_format import ParquetFormat
@@ -19,7 +22,7 @@ class FileDataSourceCreator(DataSourceCreator):
     def __init__(self, _: str):
         pass
 
-    def create_data_sources(
+    def create_data_source(
         self,
         destination: str,
         df: pd.DataFrame,
@@ -46,3 +49,79 @@ def create_offline_store_config(self) -> FeastConfigBaseModel:
 
     def teardown(self):
         self.f.close()
+
+
+class S3FileDataSourceCreator(DataSourceCreator):
+    f: Any
+    minio: DockerContainer
+    bucket = "feast-test"
+    access_key = "AKIAIOSFODNN7EXAMPLE"
+    secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+    minio_image = "minio/minio:RELEASE.2021-08-17T20-53-08Z"
+
+    def __init__(self, _: str):
+        self._setup_minio()
+
+    def _setup_minio(self):
+        self.minio = DockerContainer(self.minio_image)
+        self.minio.with_exposed_ports(9000).with_exposed_ports(9001).with_env(
+            "MINIO_ROOT_USER", self.access_key
+        ).with_env("MINIO_ROOT_PASSWORD", self.secret).with_command(
+            'server /data --console-address ":9001"'
+        )
+        self.minio.start()
+        log_string_to_wait_for = (
+            "API"  # The minio container will print "API: ..." when ready.
+        )
+        wait_for_logs(container=self.minio, predicate=log_string_to_wait_for, timeout=5)
+
+    def _upload_parquet_file(self, df, file_name, minio_endpoint):
+        self.f = tempfile.NamedTemporaryFile(suffix=".parquet", delete=False)
+        df.to_parquet(self.f.name)
+
+        client = Minio(
+            minio_endpoint,
+            access_key=self.access_key,
+            secret_key=self.secret,
+            secure=False,
+        )
+        if not client.bucket_exists(self.bucket):
+            client.make_bucket(self.bucket)
+        client.fput_object(
+            self.bucket, file_name, self.f.name,
+        )
+
+    def create_data_source(
+        self,
+        destination: str,
+        df: pd.DataFrame,
+        event_timestamp_column="ts",
+        created_timestamp_column="created_ts",
+        field_mapping: Dict[str, str] = None,
+    ) -> DataSource:
+        filename = f"{destination}.parquet"
+        port = self.minio.get_exposed_port("9000")
+        host = self.minio.get_container_host_ip()
+        minio_endpoint = f"{host}:{port}"
+
+        self._upload_parquet_file(df, filename, minio_endpoint)
+
+        return FileSource(
+            file_format=ParquetFormat(),
+            path=f"s3://{self.bucket}/{filename}",
+            event_timestamp_column=event_timestamp_column,
+            created_timestamp_column=created_timestamp_column,
+            date_partition_column="",
+            field_mapping=field_mapping or {"ts_1": "ts"},
+            s3_endpoint_override=f"http://{host}:{port}",
+        )
+
+    def get_prefixed_table_name(self, name: str, suffix: str) -> str:
+        return f"{suffix}"
+
+    def create_offline_store_config(self) -> FeastConfigBaseModel:
+        return FileOfflineStoreConfig()
+
+    def teardown(self):
+        self.minio.stop()
+        self.f.close()
diff --git a/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py b/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py
--- a/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py
+++ b/sdk/python/tests/integration/feature_repos/universal/data_sources/redshift.py
@@ -31,7 +31,7 @@ def __init__(self, project_name: str):
             iam_role="arn:aws:iam::402087665549:role/redshift_s3_access_role",
         )
 
-    def create_data_sources(
+    def create_data_source(
         self,
         destination: str,
         df: pd.DataFrame,
diff --git a/sdk/python/tests/integration/feature_repos/universal/feature_views.py b/sdk/python/tests/integration/feature_repos/universal/feature_views.py
--- a/sdk/python/tests/integration/feature_repos/universal/feature_views.py
+++ b/sdk/python/tests/integration/feature_repos/universal/feature_views.py
@@ -20,11 +20,6 @@ def create_driver_hourly_stats_feature_view(source):
     driver_stats_feature_view = FeatureView(
         name="driver_stats",
         entities=["driver"],
-        features=[
-            Feature(name="conv_rate", dtype=ValueType.FLOAT),
-            Feature(name="acc_rate", dtype=ValueType.FLOAT),
-            Feature(name="avg_daily_trips", dtype=ValueType.INT32),
-        ],
         batch_source=source,
         ttl=timedelta(hours=2),
     )
diff --git a/sdk/python/tests/integration/offline_store/test_s3_custom_endpoint.py b/sdk/python/tests/integration/offline_store/test_s3_custom_endpoint.py
new file mode 100644
--- /dev/null
+++ b/sdk/python/tests/integration/offline_store/test_s3_custom_endpoint.py
@@ -0,0 +1,35 @@
+import pytest
+
+from tests.integration.feature_repos.test_repo_configuration import (
+    TestRepoConfig,
+    construct_test_environment,
+)
+
+
+@pytest.mark.integration
+def test_registration_and_retrieval_from_custom_s3_endpoint():
+    config = TestRepoConfig(
+        offline_store_creator="tests.integration.feature_repos.universal.data_sources.file.S3FileDataSourceCreator"
+    )
+    import os
+
+    if "AWS_ACCESS_KEY_ID" in os.environ:
+        raise Exception(
+            "AWS_ACCESS_KEY_ID has already been set in the environment. Setting it again may cause a conflict. "
+            "It may be better to deduplicate AWS configuration or use sub-processes for isolation"
+        )
+
+    os.environ["AWS_ACCESS_KEY_ID"] = "AKIAIOSFODNN7EXAMPLE"
+    os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
+
+    with construct_test_environment(
+        config, create_and_apply=True, materialize=True
+    ) as environment:
+        fs = environment.feature_store
+        out = fs.get_online_features(
+            features=["driver_stats:conv_rate"], entity_rows=[{"driver": 5001}]
+        ).to_dict()
+        assert out["conv_rate"][0] is not None
+
+    del os.environ["AWS_ACCESS_KEY_ID"]
+    del os.environ["AWS_SECRET_ACCESS_KEY"]