This is an automated email from the ASF dual-hosted git repository.
Gerrrr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/otava.git
The following commit(s) were added to refs/heads/master by this push:
new de46ddf Add InfluxDB importer (#170)
de46ddf is described below
commit de46ddfc672b8dcb079e1993c10389607e006b60
Author: adambernier <[email protected]>
AuthorDate: Mon Aug 24 10:24:38 2026 -0700
Add InfluxDB importer (#170)
* Add InfluxDB importer
* Fix InfluxDB query escaping and timestamps
* Add reproducible InfluxDB example and E2E test
* Stabilize InfluxDB missing column errors
* Test InfluxDB analysis through CLI
---
README.md | 2 +-
docs/INFLUXDB.md | 99 ++++++++++++++++
docs/README.md | 1 +
examples/influxdb/admin-token.json | 5 +
examples/influxdb/data.lp | 7 ++
examples/influxdb/docker-compose.yaml | 59 ++++++++++
examples/influxdb/otava.yaml | 56 +++++++++
examples/influxdb/seed.sh | 47 ++++++++
otava/config.py | 5 +
otava/importer.py | 115 ++++++++++++++++++-
otava/influxdb.py | 67 +++++++++++
otava/test_config.py | 80 +++++++++++++
pyproject.toml | 1 +
tests/cli_help_test.py | 90 ++++++++++++++-
tests/e2e_test_utils.py | 5 +
tests/influxdb_e2e_test.py | 131 +++++++++++++++++++++
tests/influxdb_test.py | 206 ++++++++++++++++++++++++++++++++++
uv.lock | 86 +++++++++++++-
18 files changed, 1054 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index 7726d13..7f68e94 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Apache Otava – Change Detection for Continuous Performance
Engineering
Apache Otava (incubating) performs statistical analysis of performance test
results stored
-in CSV files, PostgreSQL, BigQuery, or Graphite database. It finds
change-points and notifies about
+in CSV files, PostgreSQL, BigQuery, InfluxDB 3, or Graphite database. It finds
change-points and notifies about
possible performance regressions.
A typical use-case of otava is as follows:
diff --git a/docs/INFLUXDB.md b/docs/INFLUXDB.md
new file mode 100644
index 0000000..024131f
--- /dev/null
+++ b/docs/INFLUXDB.md
@@ -0,0 +1,99 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# Importing results from InfluxDB 3
+
+Otava imports query results from InfluxDB 3 Core or Enterprise through the
+[`influxdb3-python`](https://docs.influxdata.com/influxdb3/core/reference/client-libraries/v3/python/)
+client. SQL is the default query language; set `query_language: influxql` for
+InfluxQL queries.
+
+## Connection
+
+```yaml
+influxdb:
+ host: http://localhost:8181
+ database: performance
+ token: ${INFLUXDB_TOKEN}
+```
+
+The same settings are available through `INFLUXDB_HOST`, `INFLUXDB_DATABASE`,
+and `INFLUXDB_TOKEN`, or the `--influxdb-host`, `--influxdb-database`, and
+`--influxdb-token` command-line options. Command-line values take precedence
+over environment variables, which take precedence over YAML.
+
+## Reproducible example
+
+The bundled example starts InfluxDB 3 Core with authenticated, in-memory
+storage, seeds deterministic latency data, and runs Otava against it:
+
+```bash
+docker build -t apache/otava:latest .
+docker compose -f examples/influxdb/docker-compose.yaml run --rm otava \
+ analyze api_latency_sql --branch main --since 2025-01-01
+docker compose -f examples/influxdb/docker-compose.yaml down
+```
+
+Run `api_latency_influxql` instead to query the same data with InfluxQL.
+
+The admin token committed under `examples/influxdb/` is a fixed test
+credential, and the server discards its in-memory data when stopped. Both are
+for this local demonstration only. Use a securely generated token and durable
+object storage for production deployments.
+
+## Test configuration
+
+```yaml
+tests:
+ api_latency_sql:
+ type: influxdb
+ query_language: sql
+ query: |
+ SELECT time, branch, p95_ms, commit
+ FROM api_latency
+ WHERE branch = %{BRANCH}
+ ORDER BY time
+ time_column: time
+ attributes: [branch, commit]
+ metrics:
+ p95:
+ column: p95_ms
+ direction: -1
+ scale: 1
+
+ legacy_api_latency:
+ type: influxdb
+ query_language: influxql
+ query: SELECT time, branch, p95_ms FROM api_latency WHERE branch =
%{BRANCH}
+ attributes: [branch]
+ metrics: [p95_ms]
+```
+
+Metric definitions use `column`, `direction`, and `scale` as with the other
+SQL-backed importers. `%{BRANCH}` is replaced with an escaped string literal
+when `--branch` is supplied.
+
+Run the analysis with:
+
+```bash
+otava analyze api_latency_sql --branch main --last 100
+```
+
+InfluxDB is import-only in this release; Otava does not write change points
+back to InfluxDB.
diff --git a/docs/README.md b/docs/README.md
index 2d8b970..37da1c9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -32,4 +32,5 @@
- [PostgreSQL](POSTGRESQL.md)
- [BigQuery](BIG_QUERY.md)
- [CSV](CSV.md)
+- [InfluxDB](INFLUXDB.md)
- [Annotating Change Points in Grafana](GRAFANA.md)
diff --git a/examples/influxdb/admin-token.json
b/examples/influxdb/admin-token.json
new file mode 100644
index 0000000..a10164c
--- /dev/null
+++ b/examples/influxdb/admin-token.json
@@ -0,0 +1,5 @@
+{
+ "token": "apiv3_otava_example_admin_token_2026",
+ "name": "otava-example-admin",
+ "description": "Test-only admin token for the reproducible Otava example"
+}
diff --git a/examples/influxdb/data.lp b/examples/influxdb/data.lp
new file mode 100644
index 0000000..46205f1
--- /dev/null
+++ b/examples/influxdb/data.lp
@@ -0,0 +1,7 @@
+api_latency,branch=main,commit=a1b2c3d p95_ms=87.0 1735689600000000000
+api_latency,branch=release,commit=r1e2l3s p95_ms=105.0 1735689600000000000
+api_latency,branch=main,commit=b2c3d4e p95_ms=85.0 1735776000000000000
+api_latency,branch=main,commit=c3d4e5f p95_ms=89.0 1735862400000000000
+api_latency,branch=main,commit=d4e5f6a p95_ms=118.0 1735948800000000000
+api_latency,branch=main,commit=e5f6a7b p95_ms=121.0 1736035200000000000
+api_latency,branch=main,commit=f6a7b8c p95_ms=119.0 1736121600000000000
diff --git a/examples/influxdb/docker-compose.yaml
b/examples/influxdb/docker-compose.yaml
new file mode 100644
index 0000000..68f560a
--- /dev/null
+++ b/examples/influxdb/docker-compose.yaml
@@ -0,0 +1,59 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+services:
+ influxdb:
+ image: influxdb:3.11.2-core
+ command:
+ - influxdb3
+ - serve
+ - --node-id=otava-example
+ - --object-store=memory
+ - --admin-token-file=/run/secrets/admin-token
+ ports:
+ - "8181:8181"
+ secrets:
+ - admin-token
+
+ seed:
+ image: influxdb:3.11.2-core
+ entrypoint: ["/bin/sh", "/example/seed.sh"]
+ depends_on:
+ - influxdb
+ environment:
+ INFLUXDB3_AUTH_TOKEN: apiv3_otava_example_admin_token_2026
+ INFLUXDB3_DATABASE_NAME: performance
+ INFLUXDB3_HOST_URL: http://influxdb:8181
+ volumes:
+ - .:/example:ro
+
+ otava:
+ image: apache/otava:latest
+ depends_on:
+ seed:
+ condition: service_completed_successfully
+ environment:
+ INFLUXDB_HOST: http://influxdb:8181
+ INFLUXDB_DATABASE: performance
+ INFLUXDB_TOKEN: apiv3_otava_example_admin_token_2026
+ OTAVA_CONFIG: /config/otava.yaml
+ volumes:
+ - ./otava.yaml:/config/otava.yaml:ro
+
+secrets:
+ admin-token:
+ file: ./admin-token.json
diff --git a/examples/influxdb/otava.yaml b/examples/influxdb/otava.yaml
new file mode 100644
index 0000000..9c30223
--- /dev/null
+++ b/examples/influxdb/otava.yaml
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# InfluxDB 3 connection settings can also be supplied with INFLUXDB_HOST,
+# INFLUXDB_DATABASE, and INFLUXDB_TOKEN.
+influxdb:
+ host: http://localhost:8181
+ database: performance
+ token: ${INFLUXDB_TOKEN}
+
+tests:
+ api_latency_sql:
+ type: influxdb
+ query_language: sql
+ query: |
+ SELECT time, branch, p95_ms, commit
+ FROM api_latency
+ WHERE branch = %{BRANCH}
+ ORDER BY time
+ time_column: time
+ attributes: [branch, commit]
+ metrics:
+ p95:
+ column: p95_ms
+ direction: -1
+ scale: 1
+
+ api_latency_influxql:
+ type: influxdb
+ query_language: influxql
+ query: |
+ SELECT time, branch, commit, p95_ms
+ FROM api_latency
+ WHERE branch = %{BRANCH}
+ ORDER BY time
+ time_column: time
+ attributes: [branch, commit]
+ metrics:
+ p95:
+ column: p95_ms
+ direction: -1
+ scale: 1
diff --git a/examples/influxdb/seed.sh b/examples/influxdb/seed.sh
new file mode 100755
index 0000000..3499fc7
--- /dev/null
+++ b/examples/influxdb/seed.sh
@@ -0,0 +1,47 @@
+#!/bin/sh
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -eu
+
+: "${INFLUXDB3_AUTH_TOKEN:?INFLUXDB3_AUTH_TOKEN must be set}"
+
+INFLUXDB3_HOST_URL="${INFLUXDB3_HOST_URL:-http://influxdb:8181}"
+INFLUXDB3_DATABASE_NAME="${INFLUXDB3_DATABASE_NAME:-performance}"
+SEED_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+
+export INFLUXDB3_HOST_URL INFLUXDB3_DATABASE_NAME
+
+attempt=0
+until influxdb3 show databases --format csv >/dev/null 2>&1; do
+ attempt=$((attempt + 1))
+ if [ "$attempt" -ge 60 ]; then
+ echo "InfluxDB did not become ready at $INFLUXDB3_HOST_URL" >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+if ! influxdb3 show databases --format csv | grep -Fqx
"$INFLUXDB3_DATABASE_NAME"; then
+ influxdb3 create database "$INFLUXDB3_DATABASE_NAME"
+fi
+
+influxdb3 write \
+ --database "$INFLUXDB3_DATABASE_NAME" \
+ --precision ns \
+ --file "$SEED_DIR/data.lp"
diff --git a/otava/config.py b/otava/config.py
index 11e7021..ae48ad4 100644
--- a/otava/config.py
+++ b/otava/config.py
@@ -25,6 +25,7 @@ from ruamel.yaml import YAML
from otava.bigquery import BigQueryConfig
from otava.grafana import GrafanaConfig
from otava.graphite import GraphiteConfig
+from otava.influxdb import InfluxDBConfig
from otava.postgres import PostgresConfig
from otava.slack import SlackConfig
from otava.test_config import TestConfig, create_test_config
@@ -40,6 +41,7 @@ class Config:
slack: SlackConfig
postgres: PostgresConfig
bigquery: BigQueryConfig
+ influxdb: InfluxDBConfig
@dataclass
@@ -115,6 +117,7 @@ def load_config_from_parser_args(args:
configargparse.Namespace) -> Config:
slack=SlackConfig.from_parser_args(args),
postgres=PostgresConfig.from_parser_args(args),
bigquery=BigQueryConfig.from_parser_args(args),
+ influxdb=InfluxDBConfig.from_parser_args(args),
tests=tests,
test_groups=groups,
)
@@ -133,6 +136,7 @@ class
NestedYAMLConfigFileParser(configargparse.ConfigFileParser):
SlackConfig.NAME,
PostgresConfig.NAME,
BigQueryConfig.NAME,
+ InfluxDBConfig.NAME,
]
def parse(self, stream):
@@ -180,6 +184,7 @@ def add_service_option_groups(parser) -> None:
SlackConfig.add_parser_args(parser.add_argument_group('Slack Options',
'Options for Slack configuration'))
PostgresConfig.add_parser_args(parser.add_argument_group('PostgreSQL
Options', 'Options for PostgreSQL configuration'))
BigQueryConfig.add_parser_args(parser.add_argument_group('BigQuery
Options', 'Options for BigQuery configuration'))
+ InfluxDBConfig.add_parser_args(parser.add_argument_group('InfluxDB
Options', 'Options for InfluxDB 3 configuration'))
def argument_group(parser, title: str):
diff --git a/otava/importer.py b/otava/importer.py
index c6cea32..0552405 100644
--- a/otava/importer.py
+++ b/otava/importer.py
@@ -20,7 +20,7 @@ import json
from collections import OrderedDict
from contextlib import contextmanager
from dataclasses import dataclass
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Optional, Set
@@ -30,6 +30,7 @@ from otava.bigquery import BigQuery
from otava.config import Config
from otava.data_selector import DataSelector
from otava.graphite import DataPoint, Graphite, GraphiteError
+from otava.influxdb import InfluxDB
from otava.postgres import Postgres
from otava.series import Metric, Series
from otava.test_config import (
@@ -39,6 +40,8 @@ from otava.test_config import (
CsvTestConfig,
GraphiteTestConfig,
HistoStatTestConfig,
+ InfluxDBMetric,
+ InfluxDBTestConfig,
JsonTestConfig,
PostgresMetric,
PostgresTestConfig,
@@ -827,6 +830,107 @@ class BigQueryImporter(Importer):
return [m for m in test_conf.metrics.keys()]
+class InfluxDBImporter(Importer):
+ def __init__(self, influxdb: InfluxDB):
+ self.__influxdb = influxdb
+
+ @staticmethod
+ def __selected_metrics(
+ defined_metrics: Dict[str, InfluxDBMetric], selected_metrics:
Optional[List[str]]
+ ) -> Dict[str, InfluxDBMetric]:
+ if selected_metrics is not None:
+ return {name: defined_metrics[name] for name in selected_metrics}
+ return defined_metrics
+
+ def fetch_data(self, test_conf: TestConfig, selector: DataSelector =
DataSelector()) -> Series:
+ if not isinstance(test_conf, InfluxDBTestConfig):
+ raise ValueError("Expected InfluxDBTestConfig")
+
+ since_time = selector.since_time
+ until_time = selector.until_time
+ if since_time.timestamp() > until_time.timestamp():
+ raise DataImportError(
+ f"Invalid time range:
[{format_timestamp(int(since_time.timestamp()))}, "
+ f"{format_timestamp(int(until_time.timestamp()))}]"
+ )
+
+ metrics = self.__selected_metrics(test_conf.metrics, selector.metrics)
+ query = test_conf.query
+ if "%{BRANCH}" in query:
+ if not selector.branch:
+ raise DataImportError(
+ f"Test {test_conf.name} uses %{{BRANCH}} in query but
--branch was not specified"
+ )
+ if test_conf.query_language == "influxql":
+ escaped_branch = (
+ selector.branch.replace("\\", "\\\\")
+ .replace("'", "\\'")
+ .replace("\r", "\\r")
+ .replace("\n", "\\n")
+ )
+ else:
+ escaped_branch = selector.branch.replace("'", "''")
+ branch_literal = f"'{escaped_branch}'"
+ query = query.replace("%{BRANCH}", branch_literal)
+
+ try:
+ columns, rows = self.__influxdb.fetch_data(query,
test_conf.query_language)
+ except Exception as err:
+ raise DataImportError(f"Failed to import test {test_conf.name}:
{err}") from err
+
+ required_columns = [
+ test_conf.time_column,
+ *test_conf.attributes,
+ *(metric.column for metric in metrics.values()),
+ ]
+ for column in required_columns:
+ if column not in columns:
+ raise DataImportError(f"Column not found {column!r} is not in
list")
+
+ time_index = columns.index(test_conf.time_column)
+ attr_indexes = [columns.index(column) for column in
test_conf.attributes]
+ metric_names = [metric.name for metric in metrics.values()]
+ metric_indexes = [columns.index(metric.column) for metric in
metrics.values()]
+
+ time = []
+ data = {name: [] for name in metric_names}
+ attributes = {columns[index]: [] for index in attr_indexes}
+ for row in rows:
+ timestamp = row[time_index]
+ if timestamp.tzinfo is None or timestamp.utcoffset() is None:
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
+ if timestamp < since_time or timestamp >= until_time:
+ continue
+ time.append(timestamp.timestamp())
+ for name, index in zip(metric_names, metric_indexes):
+ try:
+ data[name].append(float(row[index]))
+ except (TypeError, ValueError) as err:
+ raise DataImportError(
+ f"Could not convert value in column {columns[index]}:
{err}"
+ )
+ for index in attr_indexes:
+ attributes[columns[index]].append(row[index])
+
+ metrics = {metric.name: Metric(metric.direction, metric.scale) for
metric in metrics.values()}
+ time = time[-selector.last_n_points :]
+ data = {name: values[-selector.last_n_points :] for name, values in
data.items()}
+ attributes = {
+ name: values[-selector.last_n_points :] for name, values in
attributes.items()
+ }
+ return Series(
+ test_conf.name,
+ branch=selector.branch,
+ time=time,
+ metrics=metrics,
+ data=data,
+ attributes=attributes,
+ )
+
+ def fetch_all_metric_names(self, test_conf: InfluxDBTestConfig) ->
List[str]:
+ return list(test_conf.metrics.keys())
+
+
class Importers:
__config: Config
__csv_importer: Optional[CsvImporter]
@@ -835,6 +939,7 @@ class Importers:
__postgres_importer: Optional[PostgresImporter]
__json_importer: Optional[JsonImporter]
__bigquery_importer: Optional[BigQueryImporter]
+ __influxdb_importer: Optional[InfluxDBImporter]
def __init__(self, config: Config):
self.__config = config
@@ -844,6 +949,7 @@ class Importers:
self.__postgres_importer = None
self.__json_importer = None
self.__bigquery_importer = None
+ self.__influxdb_importer = None
def csv_importer(self) -> CsvImporter:
if self.__csv_importer is None:
@@ -875,6 +981,11 @@ class Importers:
self.__bigquery_importer =
BigQueryImporter(BigQuery(self.__config.bigquery))
return self.__bigquery_importer
+ def influxdb_importer(self) -> InfluxDBImporter:
+ if self.__influxdb_importer is None:
+ self.__influxdb_importer =
InfluxDBImporter(InfluxDB(self.__config.influxdb))
+ return self.__influxdb_importer
+
def get(self, test: TestConfig) -> Importer:
if isinstance(test, CsvTestConfig):
return self.csv_importer()
@@ -888,5 +999,7 @@ class Importers:
return self.json_importer()
elif isinstance(test, BigQueryTestConfig):
return self.bigquery_importer()
+ elif isinstance(test, InfluxDBTestConfig):
+ return self.influxdb_importer()
else:
raise ValueError(f"Unsupported test type {type(test)}")
diff --git a/otava/influxdb.py b/otava/influxdb.py
new file mode 100644
index 0000000..af5a53b
--- /dev/null
+++ b/otava/influxdb.py
@@ -0,0 +1,67 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from dataclasses import dataclass
+
+from influxdb_client_3 import InfluxDBClient3
+
+
+@dataclass
+class InfluxDBConfig:
+ NAME = "influxdb"
+
+ host: str
+ database: str
+ token: str
+
+ @staticmethod
+ def add_parser_args(arg_group):
+ arg_group.add_argument("--influxdb-host", help="InfluxDB 3 server
URL", env_var="INFLUXDB_HOST")
+ arg_group.add_argument("--influxdb-database", help="InfluxDB 3
database name", env_var="INFLUXDB_DATABASE")
+ arg_group.add_argument("--influxdb-token", help="InfluxDB 3 database
token", env_var="INFLUXDB_TOKEN")
+
+ @staticmethod
+ def from_parser_args(args):
+ return InfluxDBConfig(
+ host=getattr(args, "influxdb_host", None),
+ database=getattr(args, "influxdb_database", None),
+ token=getattr(args, "influxdb_token", None),
+ )
+
+
+class InfluxDB:
+ def __init__(self, config: InfluxDBConfig):
+ self.config = config
+ self._client = None
+
+ @property
+ def client(self) -> InfluxDBClient3:
+ if self._client is None:
+ self._client = InfluxDBClient3(
+ host=self.config.host,
+ database=self.config.database,
+ token=self.config.token,
+ )
+ return self._client
+
+ def fetch_data(self, query: str, language: str):
+ table = self.client.query(query=query, language=language)
+ columns = table.column_names
+ # Keep the public result contract consistent with the SQL importers:
+ # rows are positional tuples in the same order as ``columns``.
+ rows = [tuple(record[column] for column in columns) for record in
table.to_pylist()]
+ return columns, rows
diff --git a/otava/test_config.py b/otava/test_config.py
index 5cc57c9..db24d42 100644
--- a/otava/test_config.py
+++ b/otava/test_config.py
@@ -199,6 +199,42 @@ class BigQueryTestConfig(TestConfig):
return list(self.metrics.keys())
+@dataclass
+class InfluxDBMetric:
+ name: str
+ direction: int
+ scale: float
+ column: str
+
+
+@dataclass
+class InfluxDBTestConfig(TestConfig):
+ query: str
+ time_column: str
+ attributes: List[str]
+ metrics: Dict[str, InfluxDBMetric]
+ query_language: str
+
+ def __init__(
+ self,
+ name: str,
+ query: str,
+ time_column: str = "time",
+ metrics: List[InfluxDBMetric] = None,
+ attributes: List[str] = None,
+ query_language: str = "sql",
+ ):
+ self.name = name
+ self.query = query
+ self.time_column = time_column
+ self.metrics = {m.name: m for m in metrics} if metrics else {}
+ self.attributes = attributes if attributes is not None else []
+ self.query_language = query_language
+
+ def fully_qualified_metric_names(self) -> List[str]:
+ return list(self.metrics.keys())
+
+
def create_test_config(name: str, config: Dict) -> TestConfig:
"""
Loads properties of a test from a dictionary read from otava's config file
@@ -217,6 +253,8 @@ def create_test_config(name: str, config: Dict) ->
TestConfig:
return create_postgres_test_config(name, config)
elif test_type == "bigquery":
return create_bigquery_test_config(name, config)
+ elif test_type == "influxdb":
+ return create_influxdb_test_config(name, config)
elif test_type == "json":
return create_json_test_config(name, config)
elif test_type is None:
@@ -371,6 +409,48 @@ def create_bigquery_test_config(test_name: str, test_info:
Dict) -> BigQueryTest
raise TestConfigError(f"Configuration key not found in test
{test_name}: {e.args[0]}")
+def create_influxdb_test_config(test_name: str, test_info: Dict) ->
InfluxDBTestConfig:
+ try:
+ query = test_info["query"]
+ metrics_info = test_info["metrics"]
+ except KeyError as e:
+ raise TestConfigError(f"Configuration key not found in test
{test_name}: {e.args[0]}")
+
+ if not isinstance(metrics_info, (List, Dict)):
+ raise TestConfigError(f"Metrics of the test {test_name} must be a list
or dictionary")
+
+ metrics = []
+ if isinstance(metrics_info, List):
+ metrics = [InfluxDBMetric(metric_name, 1, 1.0, metric_name) for
metric_name in metrics_info]
+ else:
+ for metric_name, metric_conf in metrics_info.items():
+ metrics.append(
+ InfluxDBMetric(
+ name=metric_name,
+ column=metric_conf.get("column", metric_name),
+ direction=int(metric_conf.get("direction", "1")),
+ scale=float(metric_conf.get("scale", "1")),
+ )
+ )
+
+ attributes = test_info.get("attributes", [])
+ if not isinstance(attributes, List):
+ raise TestConfigError(f"Attributes of the test {test_name} must be a
list")
+ query_language = test_info.get("query_language", "sql")
+ if query_language not in ("sql", "influxql"):
+ raise TestConfigError(
+ f"Query language of the test {test_name} must be `sql` or
`influxql`"
+ )
+ return InfluxDBTestConfig(
+ test_name,
+ query=query,
+ time_column=test_info.get("time_column", "time"),
+ metrics=metrics,
+ attributes=attributes,
+ query_language=query_language,
+ )
+
+
@dataclass
class JsonTestConfig(TestConfig):
name: str
diff --git a/pyproject.toml b/pyproject.toml
index cf9ad1b..53e3762 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,6 +53,7 @@ dependencies = [
"slack-sdk>=3.39.0",
"google-cloud-bigquery>=3.38.0",
"pg8000>=1.31.5",
+ "influxdb3-python>=0.20.0",
"configargparse>=1.7.1",
"pydantic>=2,<3",
diff --git a/tests/cli_help_test.py b/tests/cli_help_test.py
index 1060578..f2ce443 100644
--- a/tests/cli_help_test.py
+++ b/tests/cli_help_test.py
@@ -56,6 +56,8 @@ usage: otava [-h] [--config-file CONFIG_FILE] [--graphite-url
GRAPHITE_URL]
[--postgres-username POSTGRES_USERNAME] [--postgres-password
POSTGRES_PASSWORD]
[--postgres-database POSTGRES_DATABASE] [--bigquery-project-id
BIGQUERY_PROJECT_ID]
[--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials
BIGQUERY_CREDENTIALS]
+ [--influxdb-host INFLUXDB_HOST] [--influxdb-database
INFLUXDB_DATABASE]
+ [--influxdb-token INFLUXDB_TOKEN]
{list-tests,list-metrics,list-groups,analyze,remove-annotations,validate} ...
Change Detection for Continuous Performance Engineering
@@ -120,6 +122,16 @@ BigQuery Options:
--bigquery-credentials BIGQUERY_CREDENTIALS
BigQuery credentials file [env var:
BIGQUERY_VAULT_SECRET]
+InfluxDB Options:
+ Options for InfluxDB 3 configuration
+
+ --influxdb-host INFLUXDB_HOST
+ InfluxDB 3 server URL [env var: INFLUXDB_HOST]
+ --influxdb-database INFLUXDB_DATABASE
+ InfluxDB 3 database name [env var: INFLUXDB_DATABASE]
+ --influxdb-token INFLUXDB_TOKEN
+ InfluxDB 3 database token [env var: INFLUXDB_TOKEN]
+
Args that start with '--' can also be set in a config file (specified via
--config-file). In
general, command-line values override environment variables which override
config file values
which override defaults.
@@ -156,8 +168,9 @@ usage: otava analyze [-h] [--config-file CONFIG_FILE]
[--graphite-url GRAPHITE_U
[--postgres-database POSTGRES_DATABASE]
[--bigquery-project-id BIGQUERY_PROJECT_ID]
[--bigquery-dataset BIGQUERY_DATASET]
- [--bigquery-credentials BIGQUERY_CREDENTIALS]
[--update-grafana]
- [--update-postgres] [--update-bigquery]
+ [--bigquery-credentials BIGQUERY_CREDENTIALS]
[--influxdb-host INFLUXDB_HOST]
+ [--influxdb-database INFLUXDB_DATABASE] [--influxdb-token
INFLUXDB_TOKEN]
+ [--update-grafana] [--update-postgres] [--update-bigquery]
[--notify-slack NOTIFY_SLACK [NOTIFY_SLACK ...]]
[--cph-report-since DATE]
[--output {{log,json,regressions_only}}] [--branch
[STRING]] [--metrics LIST]
{usage_filter_lines}
@@ -265,6 +278,16 @@ BigQuery Options:
BigQuery credentials file [env var:
BIGQUERY_VAULT_SECRET]
--update-bigquery Update BigQuery database results with change points
+InfluxDB Options:
+ Options for InfluxDB 3 configuration
+
+ --influxdb-host INFLUXDB_HOST
+ InfluxDB 3 server URL [env var: INFLUXDB_HOST]
+ --influxdb-database INFLUXDB_DATABASE
+ InfluxDB 3 database name [env var: INFLUXDB_DATABASE]
+ --influxdb-token INFLUXDB_TOKEN
+ InfluxDB 3 database token [env var: INFLUXDB_TOKEN]
+
In general, command-line values override environment variables which override
defaults.
"""
)
@@ -288,6 +311,8 @@ usage: otava list-tests [-h] [--config-file CONFIG_FILE]
[--graphite-url GRAPHIT
[--bigquery-project-id BIGQUERY_PROJECT_ID]
[--bigquery-dataset BIGQUERY_DATASET]
[--bigquery-credentials BIGQUERY_CREDENTIALS]
+ [--influxdb-host INFLUXDB_HOST] [--influxdb-database
INFLUXDB_DATABASE]
+ [--influxdb-token INFLUXDB_TOKEN]
[group ...]
positional arguments:
@@ -345,6 +370,16 @@ BigQuery Options:
--bigquery-credentials BIGQUERY_CREDENTIALS
BigQuery credentials file [env var:
BIGQUERY_VAULT_SECRET]
+InfluxDB Options:
+ Options for InfluxDB 3 configuration
+
+ --influxdb-host INFLUXDB_HOST
+ InfluxDB 3 server URL [env var: INFLUXDB_HOST]
+ --influxdb-database INFLUXDB_DATABASE
+ InfluxDB 3 database name [env var: INFLUXDB_DATABASE]
+ --influxdb-token INFLUXDB_TOKEN
+ InfluxDB 3 database token [env var: INFLUXDB_TOKEN]
+
In general, command-line values override environment variables which override
defaults.
"""
)
@@ -368,6 +403,8 @@ usage: otava list-metrics [-h] [--config-file CONFIG_FILE]
[--graphite-url GRAPH
[--bigquery-project-id BIGQUERY_PROJECT_ID]
[--bigquery-dataset BIGQUERY_DATASET]
[--bigquery-credentials BIGQUERY_CREDENTIALS]
+ [--influxdb-host INFLUXDB_HOST] [--influxdb-database
INFLUXDB_DATABASE]
+ [--influxdb-token INFLUXDB_TOKEN]
test
positional arguments:
@@ -425,6 +462,16 @@ BigQuery Options:
--bigquery-credentials BIGQUERY_CREDENTIALS
BigQuery credentials file [env var:
BIGQUERY_VAULT_SECRET]
+InfluxDB Options:
+ Options for InfluxDB 3 configuration
+
+ --influxdb-host INFLUXDB_HOST
+ InfluxDB 3 server URL [env var: INFLUXDB_HOST]
+ --influxdb-database INFLUXDB_DATABASE
+ InfluxDB 3 database name [env var: INFLUXDB_DATABASE]
+ --influxdb-token INFLUXDB_TOKEN
+ InfluxDB 3 database token [env var: INFLUXDB_TOKEN]
+
In general, command-line values override environment variables which override
defaults.
"""
)
@@ -449,6 +496,8 @@ usage: otava list-groups [-h] [--config-file CONFIG_FILE]
[--graphite-url GRAPHI
[--bigquery-project-id BIGQUERY_PROJECT_ID]
[--bigquery-dataset BIGQUERY_DATASET]
[--bigquery-credentials BIGQUERY_CREDENTIALS]
+ [--influxdb-host INFLUXDB_HOST] [--influxdb-database
INFLUXDB_DATABASE]
+ [--influxdb-token INFLUXDB_TOKEN]
options:
-h, --help show this help message and exit
@@ -502,6 +551,16 @@ BigQuery Options:
--bigquery-credentials BIGQUERY_CREDENTIALS
BigQuery credentials file [env var:
BIGQUERY_VAULT_SECRET]
+InfluxDB Options:
+ Options for InfluxDB 3 configuration
+
+ --influxdb-host INFLUXDB_HOST
+ InfluxDB 3 server URL [env var: INFLUXDB_HOST]
+ --influxdb-database INFLUXDB_DATABASE
+ InfluxDB 3 database name [env var: INFLUXDB_DATABASE]
+ --influxdb-token INFLUXDB_TOKEN
+ InfluxDB 3 database token [env var: INFLUXDB_TOKEN]
+
In general, command-line values override environment variables which override
defaults.
"""
)
@@ -525,7 +584,10 @@ usage: otava remove-annotations [-h] [--config-file
CONFIG_FILE] [--graphite-url
[--postgres-database POSTGRES_DATABASE]
[--bigquery-project-id BIGQUERY_PROJECT_ID]
[--bigquery-dataset BIGQUERY_DATASET]
- [--bigquery-credentials BIGQUERY_CREDENTIALS]
[--force]
+ [--bigquery-credentials BIGQUERY_CREDENTIALS]
+ [--influxdb-host INFLUXDB_HOST]
+ [--influxdb-database INFLUXDB_DATABASE]
+ [--influxdb-token INFLUXDB_TOKEN] [--force]
[tests ...]
positional arguments:
@@ -584,6 +646,16 @@ BigQuery Options:
--bigquery-credentials BIGQUERY_CREDENTIALS
BigQuery credentials file [env var:
BIGQUERY_VAULT_SECRET]
+InfluxDB Options:
+ Options for InfluxDB 3 configuration
+
+ --influxdb-host INFLUXDB_HOST
+ InfluxDB 3 server URL [env var: INFLUXDB_HOST]
+ --influxdb-database INFLUXDB_DATABASE
+ InfluxDB 3 database name [env var: INFLUXDB_DATABASE]
+ --influxdb-token INFLUXDB_TOKEN
+ InfluxDB 3 database token [env var: INFLUXDB_TOKEN]
+
In general, command-line values override environment variables which override
defaults.
"""
)
@@ -607,6 +679,8 @@ usage: otava validate [-h] [--config-file CONFIG_FILE]
[--graphite-url GRAPHITE_
[--bigquery-project-id BIGQUERY_PROJECT_ID]
[--bigquery-dataset BIGQUERY_DATASET]
[--bigquery-credentials BIGQUERY_CREDENTIALS]
+ [--influxdb-host INFLUXDB_HOST] [--influxdb-database
INFLUXDB_DATABASE]
+ [--influxdb-token INFLUXDB_TOKEN]
options:
-h, --help show this help message and exit
@@ -660,6 +734,16 @@ BigQuery Options:
--bigquery-credentials BIGQUERY_CREDENTIALS
BigQuery credentials file [env var:
BIGQUERY_VAULT_SECRET]
+InfluxDB Options:
+ Options for InfluxDB 3 configuration
+
+ --influxdb-host INFLUXDB_HOST
+ InfluxDB 3 server URL [env var: INFLUXDB_HOST]
+ --influxdb-database INFLUXDB_DATABASE
+ InfluxDB 3 database name [env var: INFLUXDB_DATABASE]
+ --influxdb-token INFLUXDB_TOKEN
+ InfluxDB 3 database token [env var: INFLUXDB_TOKEN]
+
In general, command-line values override environment variables which override
defaults.
"""
)
diff --git a/tests/e2e_test_utils.py b/tests/e2e_test_utils.py
index 439dcac..bb99440 100644
--- a/tests/e2e_test_utils.py
+++ b/tests/e2e_test_utils.py
@@ -29,6 +29,7 @@ import pytest
def container(
image: str,
*,
+ command: list[str] | None = None,
env: dict[str, str] | None = None,
ports: list[int] | None = None,
volumes: dict[str, str] | None = None,
@@ -39,6 +40,7 @@ def container(
Args:
image: Docker image to run (e.g., "postgres:latest").
+ command: Optional command and arguments to run instead of the image
default.
env: Optional dict of environment variables to set in the container.
ports: Optional list of container ports to publish (will be mapped to
random host ports).
volumes: Optional dict mapping host paths to container paths for
volume mounts.
@@ -76,6 +78,9 @@ def container(
cmd.append(image)
+ if command:
+ cmd.extend(command)
+
# Start the container
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if proc.returncode != 0:
diff --git a/tests/influxdb_e2e_test.py b/tests/influxdb_e2e_test.py
new file mode 100644
index 0000000..32b1603
--- /dev/null
+++ b/tests/influxdb_e2e_test.py
@@ -0,0 +1,131 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+from e2e_test_utils import _remove_trailing_whitespaces, container
+
+INFLUXDB_IMAGE = "influxdb:3.11.2-core"
+INFLUXDB_PORT = 8181
+INFLUXDB_TOKEN = "apiv3_otava_example_admin_token_2026"
+EXAMPLE_DIR = Path("examples/influxdb").resolve()
+
+
+def _analyze(test_name: str, host: str) -> str:
+ command = [
+ "uv",
+ "run",
+ "otava",
+ "analyze",
+ test_name,
+ "--influxdb-host",
+ host,
+ "--influxdb-database",
+ "performance",
+ "--influxdb-token",
+ INFLUXDB_TOKEN,
+ "--branch",
+ "main",
+ "--since",
+ "2025-01-01T00:00:00Z",
+ "--until",
+ "2025-01-07T00:00:00Z",
+ ]
+ proc = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ timeout=600,
+ env=dict(os.environ, OTAVA_CONFIG=str(EXAMPLE_DIR / "otava.yaml")),
+ )
+ if proc.returncode != 0:
+ pytest.fail(
+ "InfluxDB analysis command returned non-zero exit code.\n\n"
+ f"Command: {proc.args!r}\n"
+ f"Exit code: {proc.returncode}\n\n"
+ f"Stdout:\n{proc.stdout}\n\n"
+ f"Stderr:\n{proc.stderr}\n"
+ )
+ return _remove_trailing_whitespaces(proc.stdout)
+
+
+def test_influxdb_sql_and_influxql_return_identical_seeded_data():
+ with container(
+ INFLUXDB_IMAGE,
+ command=[
+ "influxdb3",
+ "serve",
+ "--node-id=otava-e2e",
+ "--object-store=memory",
+ "--admin-token-file=/example/admin-token.json",
+ ],
+ ports=[INFLUXDB_PORT],
+ volumes={str(EXAMPLE_DIR): "/example:ro"},
+ ) as (container_id, port_map):
+ seed = subprocess.run(
+ [
+ "docker",
+ "exec",
+ "--env",
+ f"INFLUXDB3_HOST_URL=http://127.0.0.1:{INFLUXDB_PORT}",
+ "--env",
+ f"INFLUXDB3_AUTH_TOKEN={INFLUXDB_TOKEN}",
+ "--env",
+ "INFLUXDB3_DATABASE_NAME=performance",
+ container_id,
+ "/bin/sh",
+ "/example/seed.sh",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ if seed.returncode != 0:
+ pytest.fail(
+ "InfluxDB seed command returned non-zero exit code.\n\n"
+ f"Command: {seed.args!r}\n"
+ f"Exit code: {seed.returncode}\n\n"
+ f"Stdout:\n{seed.stdout}\n\n"
+ f"Stderr:\n{seed.stderr}\n"
+ )
+
+ expected_output = textwrap.dedent(
+ """\
+ time branch commit p95
+ ------------------------- -------- -------- -----
+ 2025-01-01 00:00:00 +0000 main a1b2c3d 87
+ 2025-01-02 00:00:00 +0000 main b2c3d4e 85
+ 2025-01-03 00:00:00 +0000 main c3d4e5f 89
+ ·····
+ +37.2%
+ ·····
+ 2025-01-04 00:00:00 +0000 main d4e5f6a 118
+ 2025-01-05 00:00:00 +0000 main e5f6a7b 121
+ 2025-01-06 00:00:00 +0000 main f6a7b8c 119
+ """
+ ).rstrip("\n")
+
+ host = f"http://localhost:{port_map[INFLUXDB_PORT]}"
+ outputs = [
+ _analyze(test_name, host)
+ for test_name in ("api_latency_sql", "api_latency_influxql")
+ ]
+ assert outputs == [expected_output, expected_output]
diff --git a/tests/influxdb_test.py b/tests/influxdb_test.py
new file mode 100644
index 0000000..aea65c6
--- /dev/null
+++ b/tests/influxdb_test.py
@@ -0,0 +1,206 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+from datetime import datetime, timezone
+from unittest.mock import Mock
+
+import pyarrow as pa
+import pytest
+
+from otava.config import load_config_from_file
+from otava.data_selector import DataSelector
+from otava.importer import DataImportError, InfluxDBImporter
+from otava.influxdb import InfluxDB, InfluxDBConfig
+from otava.main import create_otava_cli_parser
+from otava.test_config import (
+ InfluxDBMetric,
+ InfluxDBTestConfig,
+ TestConfigError,
+ create_test_config,
+)
+
+
+def selector():
+ result = DataSelector()
+ result.since_time = datetime(2024, 1, 1, tzinfo=timezone.utc)
+ result.until_time = datetime(2024, 1, 5, tzinfo=timezone.utc)
+ return result
+
+
+def test_influxdb_connection_config_precedence(tmp_path, monkeypatch):
+ config_file = tmp_path / "otava.yaml"
+ config_file.write_text(
+ "influxdb:\n host: yaml-host\n database: yaml-db\n token:
yaml-token\n"
+ )
+ monkeypatch.setenv("INFLUXDB_HOST", "env-host")
+ monkeypatch.setenv("INFLUXDB_DATABASE", "env-db")
+ monkeypatch.setenv("INFLUXDB_TOKEN", "env-token")
+
+ config = load_config_from_file(
+ str(config_file),
+ arg_overrides=["--influxdb-host", "cli-host", "--influxdb-token",
"cli-token"],
+ )
+ assert config.influxdb.host == "cli-host"
+ assert config.influxdb.database == "env-db"
+ assert config.influxdb.token == "cli-token"
+ assert os.environ["INFLUXDB_HOST"] == "env-host"
+
+
+def test_cli_help_includes_influxdb_options():
+ help_text = create_otava_cli_parser().format_help()
+ assert "InfluxDB Options:" in help_text
+ assert "--influxdb-host" in help_text
+ assert "--influxdb-database" in help_text
+ assert "--influxdb-token" in help_text
+
+
+def test_influxdb_test_config_defaults_to_sql_and_parses_metrics():
+ test = create_test_config(
+ "latency",
+ {
+ "type": "influxdb",
+ "query": "SELECT * FROM latency",
+ "attributes": ["branch"],
+ "metrics": {"p95": {"column": "p95_ms", "direction": -1, "scale":
0.001}},
+ },
+ )
+ assert isinstance(test, InfluxDBTestConfig)
+ assert test.query_language == "sql"
+ assert test.metrics["p95"] == InfluxDBMetric("p95", -1, 0.001, "p95_ms")
+
+
+def test_influxdb_test_config_supports_influxql_and_rejects_unknown_language():
+ test = create_test_config(
+ "latency",
+ {"type": "influxdb", "query": "SELECT * FROM latency", "metrics":
["p95_ms"], "query_language": "influxql"},
+ )
+ assert test.query_language == "influxql"
+ with pytest.raises(TestConfigError):
+ create_test_config(
+ "latency",
+ {"type": "influxdb", "query": "SELECT * FROM latency", "metrics":
["p95_ms"], "query_language": "flux"},
+ )
+
+
+def test_influxdb_importer_reads_arrow_table_and_applies_selection():
+ client = Mock()
+ client.query.return_value = pa.table(
+ {
+ "time": pa.array(
+ [
+ datetime(2023, 12, 31),
+ datetime(2024, 1, 2),
+ datetime(2024, 1, 3),
+ datetime(2024, 1, 5),
+ ],
+ type=pa.timestamp("ns"),
+ ),
+ "branch": ["main", "main", "main", "main"],
+ "commit": ["before", "b", "c", "after"],
+ "p95_ms": [10, 20, 30, 40],
+ }
+ )
+ backend = InfluxDB(InfluxDBConfig("host", "database", "token"))
+ backend._client = client
+ test = InfluxDBTestConfig(
+ "latency",
+ "SELECT * FROM latency",
+ metrics=[InfluxDBMetric("p95", -1, 0.001, "p95_ms")],
+ attributes=["branch", "commit"],
+ )
+ chosen = selector()
+ chosen.metrics = ["p95"]
+ chosen.last_n_points = 2
+ series = InfluxDBImporter(backend).fetch_data(test, chosen)
+
+ assert series.branch is None
+ assert series.time == [1704153600.0, 1704240000.0]
+ assert series.data == {"p95": [20.0, 30.0]}
+ assert series.attributes == {"branch": ["main", "main"], "commit": ["b",
"c"]}
+ assert client.query.call_args.kwargs == {"query": "SELECT * FROM latency",
"language": "sql"}
+
+
+def test_influxdb_importer_escapes_branch_for_sql():
+ backend = Mock()
+ backend.fetch_data.return_value = (
+ ["time", "p95_ms"],
+ [(datetime(2024, 1, 2, tzinfo=timezone.utc), 4)],
+ )
+ test = InfluxDBTestConfig(
+ "latency",
+ "SELECT * FROM latency WHERE branch = %{BRANCH}",
+ metrics=[InfluxDBMetric("p95", 1, 1.0, "p95_ms")],
+ )
+ chosen = selector()
+ chosen.branch = "release'candidate"
+
+ series = InfluxDBImporter(backend).fetch_data(test, chosen)
+
+ assert series.data["p95"] == [4.0]
+ assert backend.fetch_data.call_args.args == (
+ "SELECT * FROM latency WHERE branch = 'release''candidate'",
+ "sql",
+ )
+
+
+def test_influxdb_importer_escapes_branch_for_influxql():
+ backend = Mock()
+ backend.fetch_data.return_value = (
+ ["time", "p95_ms"],
+ [(datetime(2024, 1, 2, tzinfo=timezone.utc), 4)],
+ )
+ test = InfluxDBTestConfig(
+ "latency",
+ "SELECT * FROM latency WHERE branch = %{BRANCH}",
+ query_language="influxql",
+ metrics=[InfluxDBMetric("p95", 1, 1.0, "p95_ms")],
+ )
+ chosen = selector()
+ chosen.branch = "release'candidate\\path\r\nnext"
+
+ series = InfluxDBImporter(backend).fetch_data(test, chosen)
+
+ assert series.data["p95"] == [4.0]
+ assert backend.fetch_data.call_args.args == (
+ "SELECT * FROM latency WHERE branch =
'release\\'candidate\\\\path\\r\\nnext'",
+ "influxql",
+ )
+
+
+def test_influxdb_importer_reports_missing_columns_and_client_errors():
+ test = InfluxDBTestConfig(
+ "latency",
+ "SELECT * FROM latency",
+ metrics=[InfluxDBMetric("p95", 1, 1.0, "missing")],
+ )
+ backend = Mock()
+ backend.fetch_data.return_value = (["time"], [])
+ with pytest.raises(DataImportError) as missing_error:
+ InfluxDBImporter(backend).fetch_data(test, selector())
+ assert missing_error.value.message == "Column not found 'missing' is not
in list"
+
+ backend.fetch_data.side_effect = RuntimeError("server unavailable")
+ with pytest.raises(DataImportError) as client_error:
+ InfluxDBImporter(backend).fetch_data(
+ InfluxDBTestConfig(
+ "latency", "SELECT * FROM latency",
metrics=[InfluxDBMetric("p95", 1, 1.0, "p95_ms")]
+ ),
+ selector(),
+ )
+ assert "latency" in client_error.value.message
+ assert "server unavailable" in client_error.value.message
diff --git a/uv.lock b/uv.lock
index 5802fbb..9b08a4e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -25,6 +25,7 @@ dependencies = [
{ name = "configargparse" },
{ name = "dateparser" },
{ name = "google-cloud-bigquery" },
+ { name = "influxdb3-python" },
{ name = "numpy", version = "2.2.6", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "2.3.5", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "pg8000" },
@@ -67,6 +68,7 @@ requires-dist = [
{ name = "flake8", marker = "extra == 'dev'", specifier = ">=7.3.0" },
{ name = "google-cloud-bigquery", specifier = ">=3.38.0" },
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" },
+ { name = "influxdb3-python", specifier = ">=0.20.0" },
{ name = "isort", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "numpy", marker = "python_full_version < '3.14'", specifier =
"==2.2.*" },
{ name = "numpy", marker = "python_full_version >= '3.14'", specifier =
">=2.3.2,<2.4" },
@@ -405,7 +407,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions" },
]
sdist = { url =
"https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz",
hash =
"sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size
= 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -746,6 +748,22 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl",
hash =
"sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size
= 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
+[[package]]
+name = "influxdb3-python"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "pyarrow" },
+ { name = "python-dateutil" },
+ { name = "reactivex" },
+ { name = "urllib3" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/3b/f0/3fb234e83316e439a91d4d7ee5a2f569b9b81e5a709618c4168dca3a087b/influxdb3_python-0.20.0.tar.gz",
hash =
"sha256:f1e28c2f4f244d48006beeb82be7aea82ebdd3b3e1807250d124b6f1d53c5943", size
= 102084, upload-time = "2026-06-11T06:49:53.012Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/cd/2f/06961cdb1d38d0cdb3c6876970db227e69824a293896af6d1eb983fa1534/influxdb3_python-0.20.0-py3-none-any.whl",
hash =
"sha256:0914f05c2ed9b96f2962fb3d410068dda9c411b562dab22fae1d6c6599a37927", size
= 86330, upload-time = "2026-06-11T06:49:52.016Z" },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1025,6 +1043,56 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl",
hash =
"sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size
= 22335, upload-time = "2022-10-25T20:38:27.636Z" },
]
+[[package]]
+name = "pyarrow"
+version = "25.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz",
hash =
"sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size
= 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/0a/3e/5cd70becb51e1d044c54ba5e627424a6e87df5b98008cbd22cc6abd409ca/pyarrow-25.0.1-cp310-cp310-macosx_12_0_arm64.whl",
hash =
"sha256:0b1edbb2f385a6a65e9711b62ba86ac54a7816a3f8d17bb3e8a5929d65fb2485", size
= 35954271, upload-time = "2026-08-10T12:36:33.857Z" },
+ { url =
"https://files.pythonhosted.org/packages/64/be/17599e086df264ea7dc221d1101e3131e181e00da428a2f9bd0358f0d06b/pyarrow-25.0.1-cp310-cp310-macosx_12_0_x86_64.whl",
hash =
"sha256:a4dd8bf99a8fac133efc0ed6a92f5fddbe2adba0d0f6dd720e39ba9855cea85c", size
= 37647543, upload-time = "2026-08-10T12:36:39.486Z" },
+ { url =
"https://files.pythonhosted.org/packages/42/34/e138b451fd3970a6eda4599f68ae3b2b32b661bc958de3239d54a0bf6575/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_aarch64.whl",
hash =
"sha256:bddd0c4f7630c2a3ddf6347c1bdaa79d97bcf6bd445f9e60c816b7d77c85a5ae", size
= 46837120, upload-time = "2026-08-10T12:36:46.58Z" },
+ { url =
"https://files.pythonhosted.org/packages/57/5c/f8fc0eb2de03464a557d5a4d0c15e972d73362414696618833b771f7eddd/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_x86_64.whl",
hash =
"sha256:a4d6d5e9a3d1879a97c08ded0c797579b7965eafd0f0c26c30b45ccc06db939b", size
= 50066460, upload-time = "2026-08-10T12:36:53.702Z" },
+ { url =
"https://files.pythonhosted.org/packages/3f/d1/0dd64fd06de0333b808a02f60981635f067b71aad3a30698a9a104fae778/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_aarch64.whl",
hash =
"sha256:514ddb60285631af068875550c90eddc181db3e8e63a032b1559be189e82f056", size
= 49937892, upload-time = "2026-08-10T12:37:00.349Z" },
+ { url =
"https://files.pythonhosted.org/packages/cb/3c/f89d1bd76d5f3284c2a44d7d7ebbd8204535e5ae2b41f4077069b4ff2ec6/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_x86_64.whl",
hash =
"sha256:cab40b1edfef0262e0e5251aa2c58d75630f24d06dd7794480243acc001a1d7d", size
= 53107240, upload-time = "2026-08-10T12:37:07.205Z" },
+ { url =
"https://files.pythonhosted.org/packages/67/67/b554a8e09f3f3decccf405eb8fbe86696321cbcb5b62d18b4a5057a4c113/pyarrow-25.0.1-cp310-cp310-win_amd64.whl",
hash =
"sha256:60e89d8f13861a1f7f8d950fa54aebb8023b30734d0ac51ffa80beabe2df4bba", size
= 27848683, upload-time = "2026-08-10T12:37:12.058Z" },
+ { url =
"https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl",
hash =
"sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size
= 35946180, upload-time = "2026-08-10T12:37:18.934Z" },
+ { url =
"https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl",
hash =
"sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size
= 37644787, upload-time = "2026-08-10T12:37:25.795Z" },
+ { url =
"https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl",
hash =
"sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size
= 46834633, upload-time = "2026-08-10T12:37:33.604Z" },
+ { url =
"https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl",
hash =
"sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size
= 50065507, upload-time = "2026-08-10T12:37:40.565Z" },
+ { url =
"https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl",
hash =
"sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size
= 49955690, upload-time = "2026-08-10T12:37:46.644Z" },
+ { url =
"https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl",
hash =
"sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size
= 53128198, upload-time = "2026-08-10T12:37:52.531Z" },
+ { url =
"https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl",
hash =
"sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size
= 27857263, upload-time = "2026-08-10T12:37:56.943Z" },
+ { url =
"https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl",
hash =
"sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size
= 35861559, upload-time = "2026-08-10T12:38:02.567Z" },
+ { url =
"https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl",
hash =
"sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size
= 37628383, upload-time = "2026-08-10T12:38:09.083Z" },
+ { url =
"https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl",
hash =
"sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size
= 46820190, upload-time = "2026-08-10T12:38:15.458Z" },
+ { url =
"https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl",
hash =
"sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size
= 50102437, upload-time = "2026-08-10T12:38:22.487Z" },
+ { url =
"https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl",
hash =
"sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size
= 49942424, upload-time = "2026-08-10T12:38:28.755Z" },
+ { url =
"https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl",
hash =
"sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size
= 53144206, upload-time = "2026-08-10T12:38:34.862Z" },
+ { url =
"https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl",
hash =
"sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size
= 27953934, upload-time = "2026-08-10T12:38:39.808Z" },
+ { url =
"https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl",
hash =
"sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size
= 35855328, upload-time = "2026-08-10T12:38:45.489Z" },
+ { url =
"https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl",
hash =
"sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size
= 37622415, upload-time = "2026-08-10T12:38:51.107Z" },
+ { url =
"https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl",
hash =
"sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size
= 46813813, upload-time = "2026-08-10T12:38:57.773Z" },
+ { url =
"https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl",
hash =
"sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size
= 50104452, upload-time = "2026-08-10T12:39:04.579Z" },
+ { url =
"https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl",
hash =
"sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size
= 49951343, upload-time = "2026-08-10T12:39:11.8Z" },
+ { url =
"https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl",
hash =
"sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size
= 53144784, upload-time = "2026-08-10T12:39:20.503Z" },
+ { url =
"https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl",
hash =
"sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size
= 27870159, upload-time = "2026-08-10T12:39:26.161Z" },
+ { url =
"https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl",
hash =
"sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size
= 35885255, upload-time = "2026-08-10T12:39:32.366Z" },
+ { url =
"https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl",
hash =
"sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size
= 37644461, upload-time = "2026-08-10T12:39:38.142Z" },
+ { url =
"https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl",
hash =
"sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size
= 46877146, upload-time = "2026-08-10T12:39:43.722Z" },
+ { url =
"https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl",
hash =
"sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size
= 50131616, upload-time = "2026-08-10T12:39:49.304Z" },
+ { url =
"https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl",
hash =
"sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size
= 50008879, upload-time = "2026-08-10T12:39:56.891Z" },
+ { url =
"https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl",
hash =
"sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size
= 53170864, upload-time = "2026-08-10T12:40:04.918Z" },
+ { url =
"https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl",
hash =
"sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size
= 28620729, upload-time = "2026-08-10T12:40:51.41Z" },
+ { url =
"https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl",
hash =
"sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size
= 36130288, upload-time = "2026-08-10T12:40:11.014Z" },
+ { url =
"https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl",
hash =
"sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size
= 37762187, upload-time = "2026-08-10T12:40:16.592Z" },
+ { url =
"https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl",
hash =
"sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size
= 46888003, upload-time = "2026-08-10T12:40:23.242Z" },
+ { url =
"https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl",
hash =
"sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size
= 50079036, upload-time = "2026-08-10T12:40:29.169Z" },
+ { url =
"https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl",
hash =
"sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size
= 50040226, upload-time = "2026-08-10T12:40:35.186Z" },
+ { url =
"https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size
= 53149035, upload-time = "2026-08-10T12:40:41.454Z" },
+ { url =
"https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl",
hash =
"sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size
= 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
+]
+
[[package]]
name = "pyasn1"
version = "0.6.4"
@@ -1356,6 +1424,18 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl",
hash =
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size
= 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "reactivex"
+version = "5.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/f2/c3/eeb429d774c135a8bebe2b8ac51f9639fde1953506f062b42b9ba6e44176/reactivex-5.1.0.tar.gz",
hash =
"sha256:b6b40269ebcbf24c53455c1b6790d682122cc8c01c907b8c8da47e2babb3b77e", size
= 137788, upload-time = "2026-07-27T19:07:49.114Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/e3/37/5b71117e68e5571c8c10942600eb02fb8c454c7347d08f8cddbdcde6ba6e/reactivex-5.1.0-py3-none-any.whl",
hash =
"sha256:8668c0a3c8ae8694f1180421b367489d35a8affa281a3605014bf54364eed3a0", size
= 257317, upload-time = "2026-07-27T19:07:47.631Z" },
+]
+
[[package]]
name = "regex"
version = "2025.11.3"
@@ -1590,7 +1670,7 @@ resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
- { name = "numpy", version = "2.2.6", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "numpy", version = "2.2.6", source = { registry =
"https://pypi.org/simple" } },
]
sdist = { url =
"https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz",
hash =
"sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size
= 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
wheels = [
@@ -1651,7 +1731,7 @@ resolution-markers = [
"python_full_version >= '3.11' and python_full_version < '3.13'",
]
dependencies = [
- { name = "numpy", version = "2.2.6", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and
python_full_version < '3.14'" },
+ { name = "numpy", version = "2.2.6", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "numpy", version = "2.3.5", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
]
sdist = { url =
"https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz",
hash =
"sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size
= 30597883, upload-time = "2025-10-28T17:38:54.068Z" }