From ed8b385e7d9188011cf26e62cdf2ee3fdd8d5132 Mon Sep 17 00:00:00 2001 From: Ivar Soares Urdalen Date: Tue, 19 May 2026 14:01:48 +0200 Subject: [PATCH 1/3] chore(release): 9.0.0 --- exabel/client/api/export_api.py | 273 +++++++----------- exabel/examples/entity_search_example.py | 43 +++ exabel/scripts/export_signals_v2.py | 16 +- exabel/tests/client/api/test_export_api.py | 267 +++++++++-------- .../tests/scripts/test_export_signals_v2.py | 42 +-- pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 326 insertions(+), 319 deletions(-) create mode 100644 exabel/examples/entity_search_example.py diff --git a/exabel/client/api/export_api.py b/exabel/client/api/export_api.py index b183735..3636435 100644 --- a/exabel/client/api/export_api.py +++ b/exabel/client/api/export_api.py @@ -33,7 +33,7 @@ def _read_v2_parquet(content: bytes) -> tuple[pd.DataFrame, frozenset[str]]: """Read a v2 parquet response and extract the multi-ts signal set. - The server emits ``exabel_multi_ts_signals`` (UTF-8 JSON list of labels) + The server emits exabel_multi_ts_signals (UTF-8 JSON list of labels) into the parquet schema metadata — always present, empty list when no multi-ts signals participated. A missing key indicates a contract violation (e.g. an unexpected non-server parquet). @@ -162,10 +162,10 @@ def _to_timestamp_string(value: str | pd.Timestamp) -> str: @staticmethod def _build_v2_signals( - signal: str | Column | DerivedSignal | Sequence[str | Column | DerivedSignal], + signal: str | DerivedSignal | Sequence[str | DerivedSignal], ) -> list[dict[str, str]]: """Convert signal arguments to the v2 JSON format.""" - items = [signal] if isinstance(signal, (str, Column, DerivedSignal)) else signal + items = [signal] if isinstance(signal, (str, DerivedSignal)) else signal result: list[dict[str, str]] = [] for item in items: if isinstance(item, DerivedSignal): @@ -175,11 +175,6 @@ def _build_v2_signals( f"got label={item.label!r}, expression={item.expression!r}" ) result.append({"label": item.label, "expression": item.expression}) - elif isinstance(item, Column): - entry: dict[str, str] = {"label": item.name} - if item.expression: - entry["expression"] = item.expression - result.append(entry) else: result.append({"label": item}) return result @@ -188,8 +183,8 @@ def _post_v2_signals( self, signals: list[dict[str, str]], *, - entities: list[str] | None = None, - tags: list[str] | None = None, + entities: Sequence[str] | None = None, + tags: Sequence[str] | None = None, start_time: str | pd.Timestamp | None = None, end_time: str | pd.Timestamp | None = None, version: str | pd.Timestamp | None = None, @@ -246,10 +241,10 @@ def run_export_signals_v2( self, signals: list[dict[str, str]], *, - entities: list[str] | None = None, - tags: list[str] | None = None, - start_time: str | pd.Timestamp | None = None, - end_time: str | pd.Timestamp | None = None, + start_time: str | pd.Timestamp, + end_time: str | pd.Timestamp, + entities: Sequence[str] | None = None, + tags: Sequence[str] | None = None, version: str | pd.Timestamp | None = None, ) -> pd.DataFrame: """Run a v2 signal export, returning the unprocessed server response as a DataFrame. @@ -258,6 +253,10 @@ def run_export_signals_v2( example to access Bloomberg tickers or time series labels embedded in the column headers. For a simpler interface that returns flat columns, use export_signals_v2(). + Unlike export_signals_v2, this method does NOT drop all-NaN rows — the response is + returned in the exact shape the server emitted, so sparse signals retain their + empty rows. Use export_signals_v2 if you want sparse rows stripped. + The returned DataFrame has: - A RangeIndex (integer rows). - The first column contains timestamps (column header is a tuple of level names). @@ -265,7 +264,7 @@ def run_export_signals_v2( shape — Signal and a time-series level are always present; Entity, Bloomberg ticker, and Currency levels may be present or absent depending on whether the query targets entities and whether any signals carry currency - metadata. Index levels by name (e.g. ``get_level_values("Signal")``) rather + metadata. Index levels by name (e.g. get_level_values("Signal")) rather than by position when consuming this frame. Example:: @@ -306,7 +305,6 @@ def run_export_signals_v2( @staticmethod def _reshape_v2_response( df: pd.DataFrame, - multi_entity: bool, multi_ts_signals: frozenset[str], ) -> pd.DataFrame: """Reshape a v2 response DataFrame to the signal_query format. @@ -318,12 +316,17 @@ def _reshape_v2_response( RangeIndex. Levels are addressed by name so server-side level reordering or the addition of new levels does not silently mis-route columns. - Multi-ts signals keep the ``"{signal}/{ts_name}"`` column naming for - *every* entity, even one whose query happens to match a single sub-entity + Whenever the response carries an Entity level, the result is pivoted to a + (name, time) MultiIndex regardless of how many distinct entities are + present — single- and multi-entity calls return the same row-index shape + so callers don't branch on entity count. + + Multi-ts signals keep the {signal_label}/{ts_name} column naming for + every entity, even one whose query happens to match a single sub-entity — otherwise callers cannot tell which sub-entity that value belongs to. - ``multi_ts_signals`` is the authoritative set emitted by the server in - the parquet schema metadata (see ``_read_v2_parquet``). + multi_ts_signals is the authoritative set emitted by the server in + the parquet schema metadata (see _read_v2_parquet). """ time_values = pd.DatetimeIndex(df.iloc[:, 0]) data_df = df.iloc[:, 1:] @@ -339,73 +342,61 @@ def _reshape_v2_response( time_series_labels = data_df.columns.get_level_values("Time series") has_entity_level = "Entity" in level_names entity_labels = data_df.columns.get_level_values("Entity") if has_entity_level else None - unique_signals = list(dict.fromkeys(signal_labels)) def _column_name(sig: str, ts_name: str) -> str: return f"{sig}/{ts_name}" if sig in multi_ts_signals else sig - if has_entity_level and multi_entity: + data_df = data_df.set_axis(time_values, axis=0) + + if has_entity_level: assert entity_labels is not None # narrowed by has_entity_level unique_entities = list(dict.fromkeys(entity_labels)) pieces = [] for entity in unique_entities: entity_mask = entity_labels == entity - entity_data = data_df.loc[:, entity_mask] - entity_signal_labels = signal_labels[entity_mask] - entity_ts_labels = time_series_labels[entity_mask] - - entity_df = pd.DataFrame(index=time_values) - for sig in unique_signals: - sig_mask = entity_signal_labels == sig - sig_cols = entity_data.loc[:, sig_mask] - if sig_cols.shape[1] == 0: - continue - ts_names = entity_ts_labels[sig_mask] - for i, ts_name in enumerate(ts_names): - entity_df[_column_name(sig, ts_name)] = sig_cols.iloc[:, i].values - - entity_df["__entity__"] = entity - pieces.append(entity_df) - - result = pd.concat(pieces) - result.index.name = "time" - result = result.reset_index().set_index(["__entity__", "time"]) - result.index.names = ["name", "time"] - return result - - # Single entity or no entity level - result = pd.DataFrame(index=time_values) - result.index.name = "time" - - for sig in unique_signals: - sig_mask = signal_labels == sig - sig_cols = data_df.loc[:, sig_mask] - ts_names = time_series_labels[sig_mask] - for i, ts_name in enumerate(ts_names): - result[_column_name(sig, ts_name)] = sig_cols.iloc[:, i].values + entity_slice = data_df.loc[:, entity_mask].copy() + entity_slice.columns = [ + _column_name(sig, ts_name) + for sig, ts_name in zip( + signal_labels[entity_mask], time_series_labels[entity_mask] + ) + ] + pieces.append(entity_slice) - return result + return pd.concat(pieces, keys=unique_entities, names=["name", "time"]) + + # No entity level — keep the flat DatetimeIndex. + data_df.index.name = "time" + data_df.columns = [ + _column_name(sig, ts_name) for sig, ts_name in zip(signal_labels, time_series_labels) + ] + return data_df def export_signals_v2( self, - signal: str | Column | DerivedSignal | Sequence[str | Column | DerivedSignal], + signals: str | DerivedSignal | Sequence[str | DerivedSignal], *, - resource_name: str | Sequence[str] | None = None, - tag: str | Sequence[str] | None = None, - start_time: str | pd.Timestamp | None = None, - end_time: str | pd.Timestamp | None = None, + start_time: str | pd.Timestamp, + end_time: str | pd.Timestamp, + entities: str | Sequence[str] | None = None, + tags: str | Sequence[str] | None = None, version: str | pd.Timestamp | None = None, - ) -> pd.Series | pd.DataFrame: + ) -> pd.DataFrame: """Export one or more signals using the v2 export endpoint. + Parameter names align with the wire contract on + ExportSignalsV2Request (proto exabel/export/export_service.proto): + signals, entities, tags. Pass both entities and tags in the same + call to evaluate against the union of the two sets. + Unlike signal_query(), this method uses the v2 export API which supports multi-timeseries signals (e.g., expressions using for_type() that return one time - series per sub-entity). Entities must be specified by resource_name or tag rather + series per sub-entity). Entities must be specified by entities or tags rather than bloomberg_ticker or factset_id. For multi-timeseries signals, each time series becomes a separate column named - ``"{signal_label}/{time_series_name}"`` (e.g., ``"Visits/domain1.com"``). + {signal_label}/{ts_name} (e.g., Visits/domain1.com). For access to multi-level column headers (including Bloomberg tickers and entity names), use run_export_signals_v2() instead. @@ -418,48 +409,43 @@ def export_signals_v2( label="brand_sales", expression="data('sales').for_type('brand')", ), - resource_name="entityTypes/company/entities/F_000C7F-E", + entities="entityTypes/company/entities/F_000C7F-E", start_time="2024-01-01", end_time="2024-12-31", ) Args: - signal: the signal(s) to retrieve. A string is interpreted as a signal label - from the library. Column and DerivedSignal objects allow specifying - a DSL expression with a label. At least one signal must be requested. - resource_name: an Exabel resource name such as - "entityTypes/company/entities/F_000C7F-E", or a list of such names. - tag: an Exabel tag resource name such as "tags/user:123", - or a list of such names. + signals: the signal(s) to retrieve. A string is sent to the server as a + label and resolved there — either as a saved library signal + (e.g. "close") or as a DSL expression + (e.g. "data('similarweb.all_visits')"). A DerivedSignal lets + you pass a DSL expression with an explicit label that becomes + the column header. At least one signal must be requested. + entities: one Exabel resource name such as + "entityTypes/company/entities/F_000C7F-E", or a sequence of + such names. + tags: one Exabel tag resource name such as "tags/user:123", or a + sequence of such names. Signals are exported for the union of + the entities given by entities and tags. start_time: the first date to retrieve data for. end_time: the last date to retrieve data for. version: the point-in-time at which to evaluate the signals. Returns: - A pandas Series if the result is a single time series, - or a pandas DataFrame if there are multiple time series in the result. - If a single entity was specified, the index is a DatetimeIndex. - If multiple entities or a tag was given, the index is a MultiIndex with - entity on the first level and time on the second level. + A DataFrame. Rows are a MultiIndex (name, time) whenever entities + or tags is set (the response carries an Entity level); otherwise the + rows are a DatetimeIndex. One flat column per signal, or per + {signal_label}/{ts_name} for multi-timeseries signals. All-NaN rows are dropped. """ - if not signal: - raise ValueError("Must specify signal to retrieve") - - v2_signals = self._build_v2_signals(signal) - - entities: list[str] | None = None - tags: list[str] | None = None - multi_entity = False - - if resource_name is not None: - entities = [resource_name] if isinstance(resource_name, str) else list(resource_name) - multi_entity = len(entities) > 1 - if tag is not None: - tags = [tag] if isinstance(tag, str) else list(tag) - multi_entity = True + if not signals: + raise ValueError("Must specify signals to retrieve") + if isinstance(entities, str): + entities = (entities,) + if isinstance(tags, str): + tags = (tags,) content = self._post_v2_signals( - v2_signals, + self._build_v2_signals(signals), entities=entities, tags=tags, start_time=start_time, @@ -468,20 +454,19 @@ def export_signals_v2( output_format="parquet", ) raw_df, multi_ts_signals = _read_v2_parquet(content) - df = self._reshape_v2_response( - raw_df, multi_entity=multi_entity, multi_ts_signals=multi_ts_signals - ) - return df.squeeze(axis=1).infer_objects() + df = self._reshape_v2_response(raw_df, multi_ts_signals=multi_ts_signals) + # Drop rows where every value is NaN so sparse signals don't pad the result. + return df.dropna(how="all").infer_objects() def export_signals_v2_bytes( self, - signal: str | Column | DerivedSignal | Sequence[str | Column | DerivedSignal], + signals: str | DerivedSignal | Sequence[str | DerivedSignal], *, + start_time: str | pd.Timestamp, + end_time: str | pd.Timestamp, file_format: str = "parquet", - resource_name: str | Sequence[str] | None = None, - tag: str | Sequence[str] | None = None, - start_time: str | pd.Timestamp | None = None, - end_time: str | pd.Timestamp | None = None, + entities: str | Sequence[str] | None = None, + tags: str | Sequence[str] | None = None, version: str | pd.Timestamp | None = None, ) -> bytes: """Run a v2 signal export and return the raw response bytes. @@ -489,31 +474,29 @@ def export_signals_v2_bytes( Use this when you need the exported file bytes directly — e.g. to persist to disk or forward to another system — without parsing them into a DataFrame. This is the only v2 method that works without - ``pyarrow`` installed when ``file_format`` is ``csv``, ``excel``, or - ``json``; ``parquet`` and ``feather`` bytes come back fine but reading - them back as a DataFrame still requires ``pyarrow``. + pyarrow installed when file_format is csv, excel, or json; parquet + and feather bytes come back fine but reading them back as a + DataFrame still requires pyarrow. Args: - signal: the signal(s) to retrieve, same semantics as in - :meth:`export_signals_v2`. - file_format: one of ``parquet``, ``feather``, ``csv``, ``excel``, ``json``. - resource_name: entity resource name(s) to evaluate for. - tag: tag resource name(s) to evaluate for. + signals: the signal(s) to retrieve, same semantics as in + export_signals_v2. + file_format: one of parquet, feather, csv, excel, json. + entities: entity resource name(s) to evaluate for. + tags: tag resource name(s) to evaluate for. Signals are exported for + the union of the entities given by entities and tags. start_time: first date to retrieve data for. end_time: last date to retrieve data for. version: point-in-time at which to evaluate the signals. """ - if not signal: - raise ValueError("Must specify signal to retrieve") - v2_signals = self._build_v2_signals(signal) - entities: list[str] | None = None - tags: list[str] | None = None - if resource_name is not None: - entities = [resource_name] if isinstance(resource_name, str) else list(resource_name) - if tag is not None: - tags = [tag] if isinstance(tag, str) else list(tag) + if not signals: + raise ValueError("Must specify signals to retrieve") + if isinstance(entities, str): + entities = (entities,) + if isinstance(tags, str): + tags = (tags,) return self._post_v2_signals( - v2_signals, + self._build_v2_signals(signals), entities=entities, tags=tags, start_time=start_time, @@ -522,56 +505,6 @@ def export_signals_v2_bytes( output_format=file_format, ) - def signal_query_v2( - self, - signal: str | Column | DerivedSignal | Sequence[str | Column | DerivedSignal], - *, - resource_name: str | Sequence[str] | None = None, - tag: str | Sequence[str] | None = None, - start_time: str | pd.Timestamp | None = None, - end_time: str | pd.Timestamp | None = None, - version: str | pd.Timestamp | None = None, - ) -> pd.Series | pd.DataFrame: - """Deprecated alias for :meth:`export_signals_v2`.""" - warnings.warn( - "signal_query_v2 is deprecated, use export_signals_v2 instead", - DeprecationWarning, - stacklevel=2, - ) - return self.export_signals_v2( - signal, - resource_name=resource_name, - tag=tag, - start_time=start_time, - end_time=end_time, - version=version, - ) - - def run_signal_query_v2( - self, - signals: list[dict[str, str]], - *, - entities: list[str] | None = None, - tags: list[str] | None = None, - start_time: str | pd.Timestamp | None = None, - end_time: str | pd.Timestamp | None = None, - version: str | pd.Timestamp | None = None, - ) -> pd.DataFrame: - """Deprecated alias for :meth:`run_export_signals_v2`.""" - warnings.warn( - "run_signal_query_v2 is deprecated, use run_export_signals_v2 instead", - DeprecationWarning, - stacklevel=2, - ) - return self.run_export_signals_v2( - signals, - entities=entities, - tags=tags, - start_time=start_time, - end_time=end_time, - version=version, - ) - def signal_query( self, signal: str | Column | DerivedSignal | Sequence[str | Column | DerivedSignal], diff --git a/exabel/examples/entity_search_example.py b/exabel/examples/entity_search_example.py new file mode 100644 index 0000000..7a3d383 --- /dev/null +++ b/exabel/examples/entity_search_example.py @@ -0,0 +1,43 @@ +"""Resolve company resource names with the SDK's entity search helpers. + +Most Data API calls take a resource_name like +entityTypes/company/entities/F_XXXXXX-E. +The SDK exposes a search service on client.entity_api.search that maps +common external identifiers onto the matching entity. + +This example demonstrates the three lookups callers reach for most: + +1. By Bloomberg ticker — exact, returns one entity per ticker. +2. By MIC and ticker — disambiguates a plain ticker across exchanges. +3. By free text — fuzzy match across name and ticker fields, sorted by + relevance. Returns multiple candidates per query. + +See client.api.search_service for other search methods. +""" + +from pprint import pprint + +from exabel import ExabelClient + + +def main() -> None: + """Run the three search variants and pretty-print the raw mappings each returns.""" + client = ExabelClient() + + print("\n# Bloomberg ticker") + pprint(client.entity_api.search.company_by_bloomberg_ticker("AAPL US", "GOOGL US")) + + print("\n# MIC and ticker") + pprint( + client.entity_api.search.company_by_mic_and_ticker( + ("XNAS", "AAPL"), + ("XNAS", "GOOGL"), + ) + ) + + print("\n# Free text (fuzzy, can match multiple candidates per query)") + pprint(client.entity_api.search.companies_by_text("Apple, Inc.", "Alphabet")) + + +if __name__ == "__main__": + main() diff --git a/exabel/scripts/export_signals_v2.py b/exabel/scripts/export_signals_v2.py index fdbd840..8e1ef58 100644 --- a/exabel/scripts/export_signals_v2.py +++ b/exabel/scripts/export_signals_v2.py @@ -70,14 +70,14 @@ class ExportSignalsV2(BaseScript): def __init__(self, argv: Sequence[str], description: str): super().__init__(argv, description) self.parser.add_argument( - "--signal", + "--signals", nargs="+", required=False, default=[], type=str, help=( "Signal label(s) to export from the library. " - "At least one of --signal or --expression must be given." + "At least one of --signals or --expression must be given." ), ) self.parser.add_argument( @@ -95,13 +95,13 @@ def __init__(self, argv: Sequence[str], description: str): ) entity_group = self.parser.add_mutually_exclusive_group(required=True) entity_group.add_argument( - "--resource-name", + "--entities", nargs="+", type=str, help="Entity resource name(s) to evaluate the signal(s) for.", ) entity_group.add_argument( - "--tag", + "--tags", nargs="+", type=str, help="Tag(s) whose entities should be evaluated.", @@ -136,9 +136,9 @@ def __init__(self, argv: Sequence[str], description: str): def run_script(self, client: ExabelClient, args: argparse.Namespace) -> None: start_time = time() - signals: list[str | DerivedSignal] = [*args.signal, *args.expression] + signals: list[str | DerivedSignal] = [*args.signals, *args.expression] if not signals: - self.parser.error("at least one of --signal or --expression must be provided") + self.parser.error("at least one of --signals or --expression must be provided") signal_labels = [s if isinstance(s, str) else s.label for s in signals] print("Downloading signal(s):", ", ".join(signal_labels)) @@ -148,8 +148,8 @@ def run_script(self, client: ExabelClient, args: argparse.Namespace) -> None: content = client.export_api.export_signals_v2_bytes( signals, file_format=file_format, - resource_name=args.resource_name, - tag=args.tag, + entities=args.entities, + tags=args.tags, start_time=args.start_date, end_time=args.end_date, version=args.known_time, diff --git a/exabel/tests/client/api/test_export_api.py b/exabel/tests/client/api/test_export_api.py index 694cf60..21d4a02 100644 --- a/exabel/tests/client/api/test_export_api.py +++ b/exabel/tests/client/api/test_export_api.py @@ -17,6 +17,10 @@ from exabel.query.column import Column from exabel.query.signals import Signals +# Default time range for v2 export tests where the actual dates don't matter +# (calls are mocked); spread into kwargs to satisfy the required parameters. +_V2_TIME_RANGE = {"start_time": "2024-01-01", "end_time": "2024-12-31"} + class TestExportApi: def test_signal_query(self): @@ -274,14 +278,6 @@ def test_build_v2_signals_string(self): result = ExportApi._build_v2_signals("Sales_Actual") assert result == [{"label": "Sales_Actual"}] - def test_build_v2_signals_column(self): - result = ExportApi._build_v2_signals(Column("Q", "sales_actual(alignment='afp')")) - assert result == [{"label": "Q", "expression": "sales_actual(alignment='afp')"}] - - def test_build_v2_signals_column_without_expression(self): - result = ExportApi._build_v2_signals(Column("Sales_Actual")) - assert result == [{"label": "Sales_Actual"}] - def test_build_v2_signals_derived_signal(self): signal = DerivedSignal(name=None, label="brand_sales", expression="data('sales')") result = ExportApi._build_v2_signals(signal) @@ -290,13 +286,11 @@ def test_build_v2_signals_derived_signal(self): def test_build_v2_signals_sequence(self): signals = [ "Sales_Actual", - Column("Q", "sales_actual(alignment='afp')"), DerivedSignal(name=None, label="derived", expression="expr()"), ] result = ExportApi._build_v2_signals(signals) assert result == [ {"label": "Sales_Actual"}, - {"label": "Q", "expression": "sales_actual(alignment='afp')"}, {"label": "derived", "expression": "expr()"}, ] @@ -330,8 +324,7 @@ def test_post_v2_signals_request_format(self): [{"label": "Pop", "expression": "graph_signal('ns.popularity')"}], entities=["entityTypes/company/entities/abc"], tags=["tags/user:123"], - start_time="2024-01-01", - end_time="2024-12-31", + **_V2_TIME_RANGE, version="2024-06-01", ) @@ -375,14 +368,14 @@ def test_reshape_v2_response_single_entity_single_signal(self): data=[[100, 200]], ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=False, multi_ts_signals=frozenset() - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset()) + # Single-entity responses now also pivot to (name, time) so the row-index + # shape is uniform with multi-entity responses. assert list(result.columns) == ["Popularity"] - assert list(result.index) == times - assert result.index.name == "time" - assert list(result["Popularity"]) == [100, 200] + assert result.index.names == ["name", "time"] + assert list(result.index.get_level_values("name").unique()) == ["Company A"] + assert list(result.loc["Company A"]["Popularity"]) == [100, 200] def test_reshape_v2_response_multi_entity_single_signal(self): times = [pd.Timestamp("2024-03-31"), pd.Timestamp("2024-06-30")] @@ -395,9 +388,7 @@ def test_reshape_v2_response_multi_entity_single_signal(self): data=[[100, 200], [400, 300]], ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=True, multi_ts_signals=frozenset() - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset()) assert result.index.names == ["name", "time"] assert list(result.columns) == ["Popularity"] @@ -416,9 +407,7 @@ def test_reshape_v2_response_multi_entity_multi_signal(self): data=[[100, 200], [400, 300], [1, 2]], ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=True, multi_ts_signals=frozenset() - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset()) assert result.index.names == ["name", "time"] assert sorted(result.columns) == ["Popularity", "Reputation"] @@ -438,17 +427,16 @@ def test_reshape_v2_response_multi_timeseries(self): data=[[100], [200]], ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=False, multi_ts_signals=frozenset({"Visits"}) - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset({"Visits"})) + assert result.index.names == ["name", "time"] assert "Visits/domain1.com" in result.columns assert "Visits/domain2.com" in result.columns - assert result["Visits/domain1.com"].iloc[0] == 100 - assert result["Visits/domain2.com"].iloc[0] == 200 + assert result.loc["Company A", times[0]]["Visits/domain1.com"] == 100 + assert result.loc["Company A", times[0]]["Visits/domain2.com"] == 200 def test_reshape_v2_response_no_entity_level(self): - """3-level shape: (Signal, Time series, Currency) — dataset-scoped query with no entity.""" + """3-level shape: (Signal, Time series, Currency) — query with no entity.""" times = [pd.Timestamp("2024-03-31"), pd.Timestamp("2024-06-30")] raw_df = _make_v2_response_df( time_values=times, @@ -457,9 +445,7 @@ def test_reshape_v2_response_no_entity_level(self): level_names=("Signal", "Time series", "Currency"), ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=False, multi_ts_signals=frozenset() - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset()) assert list(result.columns) == ["GDP"] assert list(result.index) == times @@ -477,18 +463,17 @@ def test_reshape_v2_response_with_currency_level_single_entity(self): level_names=("Signal", "Entity", "Time series", "Currency"), ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=False, multi_ts_signals=frozenset() - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset()) + assert result.index.names == ["name", "time"] assert list(result.columns) == ["Revenue"] - assert result["Revenue"].iloc[0] == 1000 + assert result.loc["Company A", times[0]]["Revenue"] == 1000 def test_reshape_v2_response_multi_ts_single_entity_match(self): """When a multi-ts signal matches exactly one sub-entity for a given - entity, the SDK must still produce a "signal/sub_entity" column name + entity, the SDK must still produce a {signal_label}/{ts_name} column name so the caller can tell which sub-entity the value belongs to — - multi-ts signals get the suffix for *every* entity, not just those + multi-ts signals get the suffix for every entity, not just those with multiple sub-entities in the result.""" times = [pd.Timestamp("2024-03-31")] raw_df = _make_v2_response_df( @@ -501,9 +486,7 @@ def test_reshape_v2_response_multi_ts_single_entity_match(self): data=[[100], [200], [50]], ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=True, multi_ts_signals=frozenset({"Visits"}) - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset({"Visits"})) assert result.index.names == ["name", "time"] assert result.loc["Company A", times[0]]["Visits/domain1.com"] == 100 @@ -527,9 +510,7 @@ def test_reshape_v2_response_with_currency_level_multi_entity_multi_ts(self): level_names=("Signal", "Entity", "Bloomberg ticker", "Time series", "Currency"), ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=True, multi_ts_signals=frozenset({"Visits"}) - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset({"Visits"})) assert result.index.names == ["name", "time"] assert result.loc["Company A", times[0]]["Visits/domain1.com"] == 100 @@ -552,12 +533,43 @@ def test_reshape_v2_response_multi_ts_with_name_collision(self): data=[[42]], ) - result = ExportApi._reshape_v2_response( - raw_df, multi_entity=False, multi_ts_signals=frozenset({"BrandValue"}) - ) + result = ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset({"BrandValue"})) + assert result.index.names == ["name", "time"] assert list(result.columns) == ["BrandValue/Nike Inc"] + def test_reshape_v2_response_no_performance_warning_with_many_columns(self): + """Regression: assembling the result column-by-column tripped pandas' + BlockManager fragmentation heuristic past ~100 columns, emitting a + PerformanceWarning per assignment. Real ``for_type()`` queries (e.g. + ~360 Similarweb domains per company) hit this on every call. The + reshape must stay warning-free regardless of column count.""" + times = [pd.Timestamp("2024-03-31")] + columns = [("Visits", "Company A", "COMP US", f"domain{i}.com") for i in range(200)] + data = [[i] for i in range(200)] + raw_df = _make_v2_response_df(time_values=times, columns=columns, data=data) + + with warnings.catch_warnings(): + warnings.simplefilter("error", pd.errors.PerformanceWarning) + ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset({"Visits"})) + + def test_reshape_v2_response_no_performance_warning_multi_entity(self): + """Same fragmentation guard for the multi-entity branch — it used to + build each entity's frame column-by-column too, so a watchlist of N + companies × M sub-entities tripped the same warning per entity.""" + times = [pd.Timestamp("2024-03-31")] + columns = [ + ("Visits", entity, ticker, f"domain{i}.com") + for entity, ticker in [("Company A", "COMP US"), ("Company B", "ANOT US")] + for i in range(200) + ] + data = [[i] for i in range(len(columns))] + raw_df = _make_v2_response_df(time_values=times, columns=columns, data=data) + + with warnings.catch_warnings(): + warnings.simplefilter("error", pd.errors.PerformanceWarning) + ExportApi._reshape_v2_response(raw_df, multi_ts_signals=frozenset({"Visits"})) + def test_export_signals_v2_raises_on_missing_metadata(self): """The SDK treats a response without ``exabel_multi_ts_signals`` as a server contract violation — no silent fallback to heuristics.""" @@ -568,11 +580,15 @@ def test_export_signals_v2_raises_on_missing_metadata(self): export_api._post_v2_signals = MagicMock(return_value=buffer.getvalue()) with pytest.raises(ValueError, match="exabel_multi_ts_signals"): - export_api.export_signals_v2("Sales", resource_name="entityTypes/company/entities/abc") + export_api.export_signals_v2( + "Sales", + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, + ) def test_export_signals_v2_reads_multi_ts_from_metadata(self): """End-to-end: when the server embeds the metadata, the caller sees - ``{signal}/{ts_name}`` columns even in the name-collision case.""" + {signal_label}/{ts_name} columns even in the name-collision case.""" export_api = ExportApi(ClientConfig(api_key="api-key")) times = [pd.Timestamp("2024-03-31")] raw_df = _make_v2_response_df( @@ -585,11 +601,14 @@ def test_export_signals_v2_reads_multi_ts_from_metadata(self): ) result = export_api.export_signals_v2( - "BrandValue", resource_name="entityTypes/company/entities/nke" + "BrandValue", + entities="entityTypes/company/entities/nke", + **_V2_TIME_RANGE, ) - assert isinstance(result, pd.Series) - assert result.name == "BrandValue/Nike Inc" + assert isinstance(result, pd.DataFrame) + assert list(result.columns) == ["BrandValue/Nike Inc"] + assert result.index.names == ["name", "time"] def test_export_signals_v2_single_entity(self): export_api = ExportApi(ClientConfig(api_key="api-key")) @@ -604,19 +623,20 @@ def test_export_signals_v2_single_entity(self): result = export_api.export_signals_v2( "Sales", - resource_name="entityTypes/company/entities/abc", - start_time="2024-01-01", - end_time="2024-12-31", + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, ) - # Single signal, single entity -> Series - assert isinstance(result, pd.Series) - assert result.name == "Sales" - assert list(result.index) == times + # Single signal, single entity -> DataFrame with MultiIndex (uniform shape). + assert isinstance(result, pd.DataFrame) + assert list(result.columns) == ["Sales"] + assert result.index.names == ["name", "time"] + assert list(result.index.get_level_values("time")) == times + assert list(result.index.get_level_values("name").unique()) == ["Company A"] mock_post.assert_called_once() call_kwargs = mock_post.call_args assert call_kwargs[0][0] == [{"label": "Sales"}] - assert call_kwargs[1]["entities"] == ["entityTypes/company/entities/abc"] + assert call_kwargs[1]["entities"] == ("entityTypes/company/entities/abc",) def test_export_signals_v2_multi_entity(self): export_api = ExportApi(ClientConfig(api_key="api-key")) @@ -633,10 +653,12 @@ def test_export_signals_v2_multi_entity(self): result = export_api.export_signals_v2( "Sales", - resource_name=["entityTypes/company/entities/abc", "entityTypes/company/entities/def"], + entities=["entityTypes/company/entities/abc", "entityTypes/company/entities/def"], + **_V2_TIME_RANGE, ) - assert isinstance(result, pd.Series) + assert isinstance(result, pd.DataFrame) + assert list(result.columns) == ["Sales"] assert result.index.names == ["name", "time"] def test_export_signals_v2_with_tag(self): @@ -650,9 +672,9 @@ def test_export_signals_v2_with_tag(self): mock_post = MagicMock(return_value=_df_to_parquet_bytes(raw_df)) export_api._post_v2_signals = mock_post - export_api.export_signals_v2("Sales", tag="tags/user:123") + export_api.export_signals_v2("Sales", tags="tags/user:123", **_V2_TIME_RANGE) - assert mock_post.call_args[1]["tags"] == ["tags/user:123"] + assert mock_post.call_args[1]["tags"] == ("tags/user:123",) def test_export_signals_v2_with_derived_signal(self): export_api = ExportApi(ClientConfig(api_key="api-key")) @@ -670,7 +692,9 @@ def test_export_signals_v2_with_derived_signal(self): expression="data('sales').for_type('brand')", ) result = export_api.export_signals_v2( - signal, resource_name="entityTypes/company/entities/abc" + signal, + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, ) call_args = export_api._post_v2_signals.call_args[0][0] @@ -683,8 +707,51 @@ def test_export_signals_v2_with_derived_signal(self): def test_export_signals_v2_empty_signal_raises(self): export_api = ExportApi(ClientConfig(api_key="api-key")) - with pytest.raises(ValueError, match="Must specify signal to retrieve"): - export_api.export_signals_v2([], resource_name="entityTypes/company/entities/abc") + with pytest.raises(ValueError, match="Must specify signals to retrieve"): + export_api.export_signals_v2( + [], + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, + ) + + def test_export_signals_v2_drops_all_nan_rows_keeps_partial_nan(self): + """All-NaN rows from sparse signals are dropped; rows with any value survive. + + Critical for the common case: a quarterly signal (e.g. Visible Alpha + actuals) exported across a daily range otherwise produces an output + padded with empty rows for every non-period day. + """ + export_api = ExportApi(ClientConfig(api_key="api-key")) + times = [ + pd.Timestamp("2024-03-31"), + pd.Timestamp("2024-04-01"), # all-NaN — should be dropped + pd.Timestamp("2024-06-30"), + pd.Timestamp("2024-07-01"), # partial-NaN — should survive + ] + raw_df = _make_v2_response_df( + time_values=times, + columns=[ + ("actual", "Company A", "COMP US", "Company A"), + ("consensus", "Company A", "COMP US", "Company A"), + ], + data=[ + [100, float("nan"), 200, float("nan")], + [110, float("nan"), 210, 220], + ], + ) + export_api._post_v2_signals = MagicMock(return_value=_df_to_parquet_bytes(raw_df)) + + result = export_api.export_signals_v2( + ["actual", "consensus"], + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, + ) + + result_times = list(result.index.get_level_values("time")) + assert pd.Timestamp("2024-04-01") not in result_times, "all-NaN row was not dropped" + assert pd.Timestamp("2024-07-01") in result_times, "partial-NaN row was incorrectly dropped" + assert pd.Timestamp("2024-03-31") in result_times + assert pd.Timestamp("2024-06-30") in result_times def test_run_export_signals_v2(self): export_api = ExportApi(ClientConfig(api_key="api-key")) @@ -702,8 +769,7 @@ def test_run_export_signals_v2(self): result_df = export_api.run_export_signals_v2( [{"label": "Sales"}], entities=["entityTypes/company/entities/abc"], - start_time="2024-01-01", - end_time="2024-12-31", + **_V2_TIME_RANGE, ) assert isinstance(result_df, pd.DataFrame) @@ -712,7 +778,7 @@ def test_run_export_signals_v2(self): assert result_df.shape == (2, 2) # time column + 1 data column def test_export_signals_v2_bytes_returns_raw_response(self): - """``export_signals_v2_bytes`` posts the v2 request in the requested format + """export_signals_v2_bytes posts the v2 request in the requested format and returns the server's raw bytes unchanged — no pyarrow parsing.""" export_api = ExportApi(ClientConfig(api_key="api-key")) wire_bytes = b"time,Sales\n2024-03-31,100\n" @@ -722,19 +788,22 @@ def test_export_signals_v2_bytes_returns_raw_response(self): result = export_api.export_signals_v2_bytes( "Sales", file_format="csv", - resource_name="entityTypes/company/entities/abc", - start_time="2024-01-01", - end_time="2024-12-31", + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, ) assert result == wire_bytes assert mock_post.call_args[1]["output_format"] == "csv" - assert mock_post.call_args[1]["entities"] == ["entityTypes/company/entities/abc"] + assert mock_post.call_args[1]["entities"] == ("entityTypes/company/entities/abc",) def test_export_signals_v2_bytes_empty_signal_raises(self): export_api = ExportApi(ClientConfig(api_key="api-key")) - with pytest.raises(ValueError, match="Must specify signal to retrieve"): - export_api.export_signals_v2_bytes([], resource_name="entityTypes/company/entities/abc") + with pytest.raises(ValueError, match="Must specify signals to retrieve"): + export_api.export_signals_v2_bytes( + [], + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, + ) def test_export_signals_v2_bytes_rejects_pickle(self): export_api = ExportApi(ClientConfig(api_key="api-key")) @@ -744,54 +813,16 @@ def test_export_signals_v2_bytes_rejects_pickle(self): export_api.export_signals_v2_bytes( "Sales", file_format="pickle", - resource_name="entityTypes/company/entities/abc", + entities="entityTypes/company/entities/abc", + **_V2_TIME_RANGE, ) export_api._session.post.assert_not_called() - def test_signal_query_v2_emits_deprecation_warning(self): - """The old name remains a thin wrapper that delegates to - ``export_signals_v2`` with a ``DeprecationWarning``.""" - export_api = ExportApi(ClientConfig(api_key="api-key")) - times = [pd.Timestamp("2024-03-31")] - raw_df = _make_v2_response_df( - time_values=times, - columns=[("Sales", "Company A", "COMP US", "Company A")], - data=[[100]], - ) - export_api._post_v2_signals = MagicMock(return_value=_df_to_parquet_bytes(raw_df)) - - with pytest.warns(DeprecationWarning, match="signal_query_v2 is deprecated"): - result = export_api.signal_query_v2( - "Sales", resource_name="entityTypes/company/entities/abc" - ) - assert isinstance(result, pd.Series) - - def test_run_signal_query_v2_emits_deprecation_warning(self): - export_api = ExportApi(ClientConfig(api_key="api-key")) - times = [pd.Timestamp("2024-03-31")] - raw_df = _make_v2_response_df( - time_values=times, - columns=[("Sales", "Company A", "COMP US", "Company A")], - data=[[100]], - ) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = _df_to_parquet_bytes(raw_df) - export_api._session.post = MagicMock(return_value=mock_response) - - with pytest.warns(DeprecationWarning, match="run_signal_query_v2 is deprecated"): - result = export_api.run_signal_query_v2( - [{"label": "Sales"}], entities=["entityTypes/company/entities/abc"] - ) - assert isinstance(result, pd.DataFrame) - def test_reshape_v2_response_non_multiindex(self): flat_df = pd.DataFrame({"time": [pd.Timestamp("2024-03-31")], "Sales": [100]}) - result = ExportApi._reshape_v2_response( - flat_df, multi_entity=False, multi_ts_signals=frozenset() - ) + result = ExportApi._reshape_v2_response(flat_df, multi_ts_signals=frozenset()) assert result.index.name == "time" assert list(result.columns) == ["Sales"] diff --git a/exabel/tests/scripts/test_export_signals_v2.py b/exabel/tests/scripts/test_export_signals_v2.py index bbd4ef8..8b145ab 100644 --- a/exabel/tests/scripts/test_export_signals_v2.py +++ b/exabel/tests/scripts/test_export_signals_v2.py @@ -16,7 +16,7 @@ class TestExportSignalsV2(unittest.TestCase): def setUp(self): self.common_args = [ "script", - "--signal", + "--signals", "signalA", "signalB", "--start-date", @@ -27,7 +27,7 @@ def setUp(self): ] def _assert_common_args(self, args: argparse.Namespace): - assert args.signal == ["signalA", "signalB"] + assert args.signals == ["signalA", "signalB"] assert args.start_date == pd.Timestamp("2023-01-31") assert args.end_date == pd.Timestamp("2024-02-29") @@ -36,7 +36,7 @@ def test_args_with_tag(self): self.common_args + [ "foo.csv", - "--tag", + "--tags", "tags/user:1", "tags/user:2", "--api-key", @@ -47,8 +47,8 @@ def test_args_with_tag(self): args = script.parse_arguments() self._assert_common_args(args) assert args.filename == "foo.csv" - assert args.tag == ["tags/user:1", "tags/user:2"] - assert args.resource_name is None + assert args.tags == ["tags/user:1", "tags/user:2"] + assert args.entities is None assert args.api_key == "api-key" def test_args_with_resource_name(self): @@ -56,7 +56,7 @@ def test_args_with_resource_name(self): self.common_args + [ "foo.parquet", - "--resource-name", + "--entities", "entityTypes/company/entities/A", "entityTypes/company/entities/B", "--api-key", @@ -67,11 +67,11 @@ def test_args_with_resource_name(self): args = script.parse_arguments() self._assert_common_args(args) assert args.filename == "foo.parquet" - assert args.resource_name == [ + assert args.entities == [ "entityTypes/company/entities/A", "entityTypes/company/entities/B", ] - assert args.tag is None + assert args.tags is None def test_args_env_variable(self): os.environ["EXABEL_API_KEY"] = "env_key" @@ -80,7 +80,7 @@ def test_args_env_variable(self): self.common_args + [ "foo.csv", - "--tag", + "--tags", "tags/user:1", ], "desc", @@ -97,7 +97,7 @@ def test_args_known_time(self): self.common_args + [ "foo.csv", - "--tag", + "--tags", "tags/user:1", "--api-key", "api-key", @@ -119,9 +119,9 @@ def test_args_tag_and_resource_name_mutually_exclusive(self): self.common_args + [ "foo.csv", - "--tag", + "--tags", "tags/user:1", - "--resource-name", + "--entities", "entityTypes/company/entities/A", "--api-key", "api-key", @@ -136,7 +136,7 @@ def test_args_unknown_file_extension(self): self.common_args + [ "foo.pdf", - "--tag", + "--tags", "tags/user:1", "--api-key", "api-key", @@ -147,7 +147,7 @@ def test_args_unknown_file_extension(self): script.parse_arguments() def test_args_expression_only(self): - """--expression alone (no --signal) should parse and produce DerivedSignal objects.""" + """--expression alone (no --signals) should parse and produce DerivedSignal objects.""" script = ExportSignalsV2( [ "script", @@ -159,7 +159,7 @@ def test_args_expression_only(self): "2024-02-29", "--filename", "foo.csv", - "--tag", + "--tags", "tags/user:1", "--api-key", "api-key", @@ -167,7 +167,7 @@ def test_args_expression_only(self): "desc", ) args = script.parse_arguments() - assert args.signal == [] + assert args.signals == [] assert len(args.expression) == 1 derived = args.expression[0] assert isinstance(derived, DerivedSignal) @@ -182,7 +182,7 @@ def test_args_expression_without_equals_sign_raises(self): "missing_separator", "--filename", "foo.csv", - "--tag", + "--tags", "tags/user:1", "--api-key", "api-key", @@ -201,7 +201,7 @@ def test_args_expression_preserves_equals_in_expression_body(self): 'cmp=data("x") == 1', "--filename", "foo.csv", - "--tag", + "--tags", "tags/user:1", "--api-key", "api-key", @@ -224,7 +224,7 @@ def test_run_script_writes_raw_bytes_and_derives_format_from_extension(self): self.common_args + [ filename, - "--tag", + "--tags", "tags/user:1", "--api-key", "api-key", @@ -241,7 +241,7 @@ def test_run_script_writes_raw_bytes_and_derives_format_from_extension(self): client.export_api.export_signals_v2_bytes.assert_called_once() kwargs = client.export_api.export_signals_v2_bytes.call_args.kwargs assert kwargs["file_format"] == "csv" - assert kwargs["tag"] == ["tags/user:1"] + assert kwargs["tags"] == ["tags/user:1"] assert kwargs["start_time"] == pd.Timestamp("2023-01-31") assert kwargs["end_time"] == pd.Timestamp("2024-02-29") assert Path(filename).read_bytes() == wire_bytes @@ -254,7 +254,7 @@ def test_run_script_derives_excel_format_from_xlsx_extension(self): self.common_args + [ filename, - "--tag", + "--tags", "tags/user:1", "--api-key", "api-key", diff --git a/pyproject.toml b/pyproject.toml index d4f83e6..fbf39c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "exabel" description = "Python SDK for the Exabel Data API" -version = "8.2.0" +version = "9.0.0" license = "MIT" license-files = ["LICENSE"] readme = "README.md" diff --git a/uv.lock b/uv.lock index 99c6f94..2ee540d 100644 --- a/uv.lock +++ b/uv.lock @@ -314,7 +314,7 @@ wheels = [ [[package]] name = "exabel" -version = "8.2.0" +version = "9.0.0" source = { editable = "." } dependencies = [ { name = "google-api-core" }, From 11e1a040c1a41d6828c7e9007ba6f56fc12f7e92 Mon Sep 17 00:00:00 2001 From: Ivar Soares Urdalen Date: Tue, 19 May 2026 14:20:03 +0200 Subject: [PATCH 2/3] chore: minor fixes on versions --- .github/workflows/build_test.yml | 4 ++-- .gitignore | 1 + pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index b062a51..d20dc0f 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -24,7 +24,7 @@ jobs: python-version-file: ".python-version" - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8 - name: Install dependencies and build package run: | @@ -54,7 +54,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v8 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.gitignore b/.gitignore index aaa585d..d139179 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ __pycache__ /.idea/ .claude/ .vscode/ +.fresh/ diff --git a/pyproject.toml b/pyproject.toml index fbf39c7..3c69ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ Source = "https://github.com/Exabel/python-sdk" Changelog = "https://github.com/Exabel/python-sdk/releases" [build-system] -requires = ["uv_build>=0.9.28"] +requires = ["uv_build>=0.11.15,<0.12"] build-backend = "uv_build" [tool.uv] From b4eef91fd7e7c316cd53bef07eb1b7f3bbac80fe Mon Sep 17 00:00:00 2001 From: Ivar Soares Urdalen Date: Tue, 19 May 2026 14:22:23 +0200 Subject: [PATCH 3/3] chore: minor fix on GHA --- .github/workflows/build_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index d20dc0f..31abb66 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -24,7 +24,7 @@ jobs: python-version-file: ".python-version" - name: Install uv - uses: astral-sh/setup-uv@v8 + uses: astral-sh/setup-uv@v8.1.0 - name: Install dependencies and build package run: | @@ -54,7 +54,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v8 + uses: astral-sh/setup-uv@v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }}