Repository: feast-dev/feast

Issue:
Run feast cli command without cd into feature repo directory
**Is your feature request related to a problem? Please describe.**
Is it possible to run a feast command i.e. feast apply, without cd into the feature repo directory?

**Describe the solution you'd like**
feast -p feature_repo apply
feast -p feature_repo materialize-incremental $CURRENT_TIME

**Possible solution**
PR #1509


Discussion:

Resolving patch:
diff --git a/sdk/python/feast/cli.py b/sdk/python/feast/cli.py
--- a/sdk/python/feast/cli.py
+++ b/sdk/python/feast/cli.py
@@ -39,7 +39,13 @@
 
 
 @click.group()
-def cli():
+@click.option(
+    "--chdir",
+    "-c",
+    help="Switch to a different feature repository directory before executing the given subcommand.",
+)
+@click.pass_context
+def cli(ctx: click.Context, chdir: str):
     """
     Feast CLI
 
@@ -47,6 +53,8 @@ def cli():
 
     For any questions, you can reach us at https://slack.feast.dev/
     """
+    ctx.ensure_object(dict)
+    ctx.obj["CHDIR"] = Path.cwd() if chdir is None else Path(chdir).absolute()
     pass
 
 
@@ -68,12 +76,14 @@ def entities_cmd():
 
 @entities_cmd.command("describe")
 @click.argument("name", type=click.STRING)
-def entity_describe(name: str):
+@click.pass_context
+def entity_describe(ctx: click.Context, name: str):
     """
     Describe an entity
     """
-    cli_check_repo(Path.cwd())
-    store = FeatureStore(repo_path=str(Path.cwd()))
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    store = FeatureStore(repo_path=str(repo))
 
     try:
         entity = store.get_entity(name)
@@ -89,12 +99,14 @@ def entity_describe(name: str):
 
 
 @entities_cmd.command(name="list")
-def entity_list():
+@click.pass_context
+def entity_list(ctx: click.Context):
     """
     List all entities
     """
-    cli_check_repo(Path.cwd())
-    store = FeatureStore(repo_path=str(Path.cwd()))
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    store = FeatureStore(repo_path=str(repo))
     table = []
     for entity in store.list_entities():
         table.append([entity.name, entity.description, entity.value_type])
@@ -114,12 +126,14 @@ def feature_views_cmd():
 
 @feature_views_cmd.command("describe")
 @click.argument("name", type=click.STRING)
-def feature_view_describe(name: str):
+@click.pass_context
+def feature_view_describe(ctx: click.Context, name: str):
     """
     Describe a feature view
     """
-    cli_check_repo(Path.cwd())
-    store = FeatureStore(repo_path=str(Path.cwd()))
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    store = FeatureStore(repo_path=str(repo))
 
     try:
         feature_view = store.get_feature_view(name)
@@ -135,12 +149,14 @@ def feature_view_describe(name: str):
 
 
 @feature_views_cmd.command(name="list")
-def feature_view_list():
+@click.pass_context
+def feature_view_list(ctx: click.Context):
     """
     List all feature views
     """
-    cli_check_repo(Path.cwd())
-    store = FeatureStore(repo_path=str(Path.cwd()))
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    store = FeatureStore(repo_path=str(repo))
     table = []
     for feature_view in store.list_feature_views():
         table.append([feature_view.name, feature_view.entities])
@@ -151,44 +167,50 @@ def feature_view_list():
 
 
 @cli.command("apply")
-def apply_total_command():
+@click.pass_context
+def apply_total_command(ctx: click.Context):
     """
     Create or update a feature store deployment
     """
-    cli_check_repo(Path.cwd())
-    repo_config = load_repo_config(Path.cwd())
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    repo_config = load_repo_config(repo)
     tele = Telemetry()
     tele.log("apply")
     try:
-        apply_total(repo_config, Path.cwd())
+        apply_total(repo_config, repo)
     except FeastProviderLoginError as e:
         print(str(e))
 
 
 @cli.command("teardown")
-def teardown_command():
+@click.pass_context
+def teardown_command(ctx: click.Context):
     """
     Tear down deployed feature store infrastructure
     """
-    cli_check_repo(Path.cwd())
-    repo_config = load_repo_config(Path.cwd())
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    repo_config = load_repo_config(repo)
     tele = Telemetry()
     tele.log("teardown")
 
-    teardown(repo_config, Path.cwd())
+    teardown(repo_config, repo)
 
 
 @cli.command("registry-dump")
-def registry_dump_command():
+@click.pass_context
+def registry_dump_command(ctx: click.Context):
     """
     Print contents of the metadata registry
     """
-    cli_check_repo(Path.cwd())
-    repo_config = load_repo_config(Path.cwd())
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    repo_config = load_repo_config(repo)
     tele = Telemetry()
     tele.log("registry-dump")
 
-    registry_dump(repo_config, repo_path=Path.cwd())
+    registry_dump(repo_config, repo_path=repo)
 
 
 @cli.command("materialize")
@@ -197,7 +219,10 @@ def registry_dump_command():
 @click.option(
     "--views", "-v", help="Feature views to materialize", multiple=True,
 )
-def materialize_command(start_ts: str, end_ts: str, views: List[str]):
+@click.pass_context
+def materialize_command(
+    ctx: click.Context, start_ts: str, end_ts: str, views: List[str]
+):
     """
     Run a (non-incremental) materialization job to ingest data into the online store. Feast
     will read all data between START_TS and END_TS from the offline store and write it to the
@@ -206,8 +231,9 @@ def materialize_command(start_ts: str, end_ts: str, views: List[str]):
 
     START_TS and END_TS should be in ISO 8601 format, e.g. '2021-07-16T19:20:01'
     """
-    cli_check_repo(Path.cwd())
-    store = FeatureStore(repo_path=str(Path.cwd()))
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    store = FeatureStore(repo_path=str(repo))
     store.materialize(
         feature_views=None if not views else views,
         start_date=datetime.fromisoformat(start_ts),
@@ -220,7 +246,8 @@ def materialize_command(start_ts: str, end_ts: str, views: List[str]):
 @click.option(
     "--views", "-v", help="Feature views to incrementally materialize", multiple=True,
 )
-def materialize_incremental_command(end_ts: str, views: List[str]):
+@click.pass_context
+def materialize_incremental_command(ctx: click.Context, end_ts: str, views: List[str]):
     """
     Run an incremental materialization job to ingest new data into the online store. Feast will read
     all data from the previously ingested point to END_TS from the offline store and write it to the
@@ -229,8 +256,9 @@ def materialize_incremental_command(end_ts: str, views: List[str]):
 
     END_TS should be in ISO 8601 format, e.g. '2021-07-16T19:20:01'
     """
-    cli_check_repo(Path.cwd())
-    store = FeatureStore(repo_path=str(Path.cwd()))
+    repo = ctx.obj["CHDIR"]
+    cli_check_repo(repo)
+    store = FeatureStore(repo_path=str(repo))
     store.materialize_incremental(
         feature_views=None if not views else views,
         end_date=datetime.fromisoformat(end_ts),


Test patch:
diff --git a/sdk/python/tests/test_cli_chdir.py b/sdk/python/tests/test_cli_chdir.py
new file mode 100644
--- /dev/null
+++ b/sdk/python/tests/test_cli_chdir.py
@@ -0,0 +1,56 @@
+import tempfile
+from datetime import datetime, timedelta
+from pathlib import Path
+
+from tests.cli_utils import CliRunner
+
+
+def test_cli_chdir() -> None:
+    """
+    This test simply makes sure that you can run 'feast --chdir COMMAND'
+    to switch to a feature repository before running a COMMAND.
+    """
+    runner = CliRunner()
+    with tempfile.TemporaryDirectory() as temp_dir:
+        # Make sure the path is absolute by resolving any symlinks
+        temp_path = Path(temp_dir).resolve()
+        result = runner.run(["init", "my_project"], cwd=temp_path)
+        repo_path = temp_path / "my_project"
+        assert result.returncode == 0
+
+        result = runner.run(["--chdir", repo_path, "apply"], cwd=temp_path)
+        assert result.returncode == 0
+
+        result = runner.run(["--chdir", repo_path, "entities", "list"], cwd=temp_path)
+        assert result.returncode == 0
+
+        result = runner.run(
+            ["--chdir", repo_path, "feature-views", "list"], cwd=temp_path
+        )
+        assert result.returncode == 0
+
+        end_date = datetime.utcnow()
+        start_date = end_date - timedelta(days=100)
+        result = runner.run(
+            [
+                "--chdir",
+                repo_path,
+                "materialize",
+                start_date.isoformat(),
+                end_date.isoformat(),
+            ],
+            cwd=temp_path,
+        )
+        assert result.returncode == 0
+
+        result = runner.run(
+            ["--chdir", repo_path, "materialize-incremental", end_date.isoformat()],
+            cwd=temp_path,
+        )
+        assert result.returncode == 0
+
+        result = runner.run(["--chdir", repo_path, "registry-dump"], cwd=temp_path)
+        assert result.returncode == 0
+
+        result = runner.run(["--chdir", repo_path, "teardown"], cwd=temp_path)
+        assert result.returncode == 0