diff --git a/docs/roadmap.md b/docs/roadmap.md index cb55873c3fa..0607dab1309 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,6 +62,8 @@ The list below contains the functionality that contributors are planning to deve * [x] [Python feature server](https://docs.feast.dev/reference/feature-servers/python-feature-server) * [x] [Java feature server (alpha)](https://github.com/feast-dev/feast/blob/master/infra/charts/feast/README.md) * [x] [Go feature server (alpha)](https://docs.feast.dev/reference/feature-servers/go-feature-server) + * [x] [Offline Feature Server (alpha)](https://docs.feast.dev/reference/feature-servers/offline-feature-server) + * [x] [Registry server (alpha)](https://github.com/feast-dev/feast/blob/master/docs/reference/feature-servers/registry-server.md) * **Data Quality Management (See [RFC](https://docs.google.com/document/d/110F72d4NTv80p35wDSONxhhPBqWRwbZXG4f9mNEMd98/edit))** * [x] Data profiling and validation (Great Expectations) * **Feature Discovery and Governance** diff --git a/infra/templates/README.md.jinja2 b/infra/templates/README.md.jinja2 index 4b9ed71fed9..e58e8b1aadb 100644 --- a/infra/templates/README.md.jinja2 +++ b/infra/templates/README.md.jinja2 @@ -152,6 +152,7 @@ pprint(feature_vector) Please refer to the official documentation at [Documentation](https://docs.feast.dev/) * [Quickstart](https://docs.feast.dev/getting-started/quickstart) * [Tutorials](https://docs.feast.dev/tutorials/tutorials-overview) + * [Examples](https://github.com/feast-dev/feast/tree/master/examples) * [Running Feast with Snowflake/GCP/AWS](https://docs.feast.dev/how-to-guides/feast-snowflake-gcp-aws) * [Change Log](https://github.com/feast-dev/feast/blob/master/CHANGELOG.md) diff --git a/sdk/python/feast/cli.py b/sdk/python/feast/cli.py index 3c9b7ddcbf1..81dac70a9fb 100644 --- a/sdk/python/feast/cli.py +++ b/sdk/python/feast/cli.py @@ -19,6 +19,7 @@ from typing import Any, List, Optional import click +import pandas as pd import yaml from bigtree import Node from colorama import Fore, Style @@ -537,6 +538,175 @@ def feature_view_list(ctx: click.Context, tags: list[str]): print(tabulate(table, headers=["NAME", "ENTITIES", "TYPE"], tablefmt="plain")) +@cli.group(name="features") +def features_cmd(): + """ + Access features + """ + pass + + +@features_cmd.command(name="list") +@click.option( + "--output", + type=click.Choice(["table", "json"], case_sensitive=False), + default="table", + show_default=True, + help="Output format", +) +@click.pass_context +def features_list(ctx: click.Context, output: str): + """ + List all features + """ + store = create_feature_store(ctx) + feature_views = [ + *store.list_batch_feature_views(), + *store.list_on_demand_feature_views(), + *store.list_stream_feature_views(), + ] + feature_list = [] + for fv in feature_views: + for feature in fv.features: + feature_list.append([feature.name, fv.name, str(feature.dtype)]) + + if output == "json": + json_output = [ + {"feature_name": fn, "feature_view": fv, "dtype": dt} + for fv, fn, dt in feature_list + ] + click.echo(json.dumps(json_output, indent=4)) + else: + from tabulate import tabulate + + click.echo( + tabulate( + feature_list, + headers=["Feature", "Feature View", "Data Type"], + tablefmt="plain", + ) + ) + + +@features_cmd.command("describe") +@click.argument("feature_name", type=str) +@click.pass_context +def describe_feature(ctx: click.Context, feature_name: str): + """ + Describe a specific feature by name + """ + store = create_feature_store(ctx) + feature_views = [ + *store.list_batch_feature_views(), + *store.list_on_demand_feature_views(), + *store.list_stream_feature_views(), + ] + + feature_details = [] + for fv in feature_views: + for feature in fv.features: + if feature.name == feature_name: + feature_details.append( + { + "Feature Name": feature.name, + "Feature View": fv.name, + "Data Type": str(feature.dtype), + "Description": getattr(feature, "description", "N/A"), + "Online Store": getattr(fv, "online", "N/A"), + "Source": json.loads(str(getattr(fv, "batch_source", "N/A"))), + } + ) + if not feature_details: + click.echo(f"Feature '{feature_name}' not found in any feature view.") + return + + click.echo(json.dumps(feature_details, indent=4)) + + +@cli.command("get-online-features") +@click.option( + "--entities", + "-e", + type=str, + multiple=True, + required=True, + help="Entity key-value pairs (e.g., driver_id=1001)", +) +@click.option( + "--features", + "-f", + multiple=True, + required=True, + help="Features to retrieve. (e.g.,feature-view:feature-name) ex: driver_hourly_stats:conv_rate", +) +@click.pass_context +def get_online_features(ctx: click.Context, entities: List[str], features: List[str]): + """ + Fetch online feature values for a given entity ID + """ + store = create_feature_store(ctx) + entity_dict: dict[str, List[str]] = {} + for entity in entities: + try: + key, value = entity.split("=") + if key not in entity_dict: + entity_dict[key] = [] + entity_dict[key].append(value) + except ValueError: + click.echo(f"Invalid entity format: {entity}. Use key=value format.") + return + entity_rows = [ + dict(zip(entity_dict.keys(), values)) for values in zip(*entity_dict.values()) + ] + feature_vector = store.get_online_features( + features=list(features), + entity_rows=entity_rows, + ).to_dict() + + click.echo(json.dumps(feature_vector, indent=4)) + + +@cli.command(name="get-historical-features") +@click.option( + "--dataframe", + "-d", + type=str, + required=True, + help='JSON string containing entities and timestamps. Example: \'[{"event_timestamp": "2025-03-29T12:00:00", "driver_id": 1001}]\'', +) +@click.option( + "--features", + "-f", + multiple=True, + required=True, + help="Features to retrieve. feature-view:feature-name ex: driver_hourly_stats:conv_rate", +) +@click.pass_context +def get_historical_features(ctx: click.Context, dataframe: str, features: List[str]): + """ + Fetch historical feature values for a given entity ID + """ + store = create_feature_store(ctx) + try: + entity_list = json.loads(dataframe) + if not isinstance(entity_list, list): + raise ValueError("Entities must be a list of dictionaries.") + + entity_df = pd.DataFrame(entity_list) + entity_df["event_timestamp"] = pd.to_datetime(entity_df["event_timestamp"]) + + except Exception as e: + click.echo(f"Error parsing entities JSON: {e}", err=True) + return + + feature_vector = store.get_historical_features( + entity_df=entity_df, + features=list(features), + ).to_df() + + click.echo(feature_vector.to_json(orient="records", indent=4)) + + @cli.group(name="on-demand-feature-views") def on_demand_feature_views_cmd(): """