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 d2d51c9 Make external service clients optional (#176)
d2d51c9 is described below
commit d2d51c9c0b13b416ce191f25b7b713e1f343c30d
Author: Alex Sorokoumov <[email protected]>
AuthorDate: Wed Sep 2 23:35:55 2026 -0700
Make external service clients optional (#176)
* Make external service clients optional
Otava currently installs every database and notification client even when
callers only need the analysis library, bundled CLI, or built-in data sources.
Split BigQuery, PostgreSQL, InfluxDB, Grafana, and Slack clients into
installable extras so users pay only for the integrations they need.
Keep the production image's existing all-integrations behavior through the
all extra, move contributor tools into the unpublished dev dependency group,
load optional clients lazily with actionable installation errors, and verify a
clean core wheel in CI. Update installation and integration documentation to
describe the new choices.
* Fix documentation examples found during validation
Running the documented workflows exposed stale paths, timestamps, output,
date selectors, and malformed code fences. Add a runnable source-checkout CSV
configuration with regression coverage and align the CSV, Graphite, Grafana,
PostgreSQL, and InfluxDB examples with the bundled fixtures.
---
.github/workflows/python-app.yml | 27 ++++++
Dockerfile | 5 +-
README.md | 20 +++++
docs/BASICS.md | 39 ++++----
docs/BIG_QUERY.md | 6 ++
docs/CSV.md | 25 +++---
docs/GETTING_STARTED.md | 59 +++++++-----
docs/GRAFANA.md | 15 +++-
docs/GRAPHITE.md | 6 +-
docs/INFLUXDB.md | 10 ++-
docs/INSTALL.md | 22 +++++
docs/POSTGRESQL.md | 35 +++++---
examples/csv/config/otava-local.yaml | 27 ++++++
otava/_optional.py | 39 ++++++++
otava/bigquery.py | 27 ++++--
otava/grafana.py | 17 ++--
otava/importer.py | 11 ++-
otava/influxdb.py | 17 +++-
otava/main.py | 22 +++--
otava/postgres.py | 17 +++-
otava/slack.py | 16 +++-
pyproject.toml | 47 ++++++----
tests/core_install_smoke.py | 130 +++++++++++++++++++++++++++
tests/csv_e2e_test.py | 45 ++++++++++
tests/optional_dependencies_test.py | 169 +++++++++++++++++++++++++++++++++++
uv.lock | 78 ++++++++++------
26 files changed, 781 insertions(+), 150 deletions(-)
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 0e8faf5..8798d9e 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -30,6 +30,33 @@ permissions:
contents: read
jobs:
+ core-install:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python 3.14
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.14"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 #
v9.0.0
+ with:
+ python-version: "3.14"
+ prune-cache: true
+
+ - name: Build the wheel
+ run: uv build --wheel
+
+ - name: Install without extras
+ run: |
+ uv venv --python 3.14 .core-venv
+ uv pip install --python .core-venv/bin/python dist/*.whl
+
+ - name: Test the default installation
+ run: .core-venv/bin/python tests/core_install_smoke.py
+
build:
runs-on: ubuntu-latest
strategy:
diff --git a/Dockerfile b/Dockerfile
index 1f67460..6a50619 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -59,8 +59,9 @@ COPY --from=builder /build/dist/*.whl /tmp/
# Install the wheel and remove temporary artifacts.
# With the slim runtime image this should resolve binary dependencies from
# prebuilt wheels instead of compiling NumPy/SciPy from source.
-RUN uv pip install --system --no-cache /tmp/apache_otava-*.whl \
- && rm /tmp/apache_otava-*.whl \
+RUN set -- /tmp/apache_otava-*.whl \
+ && uv pip install --system --no-cache "${1}[all]" \
+ && rm "${1}" \
&& rm /usr/local/bin/uv
# Switch to otava user
diff --git a/README.md b/README.md
index 7f68e94..323b40f 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,26 @@ integrated with a pull request.
See the documentation in https://otava.apache.org/docs/overview/.
+## Installation
+
+The default package includes the Otava library and CLI, with support for CSV,
+JSON, and Graphite data:
+
+```bash
+pip install apache-otava
+```
+
+Install extras for additional integrations. Extras can be combined, or install
+`all` to enable every integration:
+
+```bash
+pip install 'apache-otava[bigquery]'
+pip install 'apache-otava[postgres,slack]'
+pip install 'apache-otava[all]'
+```
+
+See the [installation guide](docs/INSTALL.md) for the complete list of extras.
+
## Supported Python Versions
Apache Otava is tested against Python 3.10, 3.11, 3.12, 3.13, and 3.14.
diff --git a/docs/BASICS.md b/docs/BASICS.md
index 89f64da..352fdc5 100644
--- a/docs/BASICS.md
+++ b/docs/BASICS.md
@@ -78,29 +78,28 @@ The results are simply concatenated.
### Example
> [!TIP]
-> See [otava.yaml](../examples/csv/config/otava.yaml) for the full
+> See [otava-local.yaml](../examples/csv/config/otava-local.yaml) for the full
> example configuration and
> [local_sample.csv](../examples/csv/data/local_sample.csv)
> for the data.
-```
-$ otava analyze local.sample --since=2024-01-01
-INFO: Computing change points for test sample.csv...
-sample:
-time metric1 metric2
-------------------------- --------- ---------
-2021-01-01 02:00:00 +0000 154023 10.43
-2021-01-02 02:00:00 +0000 138455 10.23
-2021-01-03 02:00:00 +0000 143112 10.29
-2021-01-04 02:00:00 +0000 149190 10.91
-2021-01-05 02:00:00 +0000 132098 10.34
-2021-01-06 02:00:00 +0000 151344 10.69
- ·········
- -12.9%
- ·········
-2021-01-07 02:00:00 +0000 155145 9.23
-2021-01-08 02:00:00 +0000 148889 9.11
-2021-01-09 02:00:00 +0000 149466 9.13
-2021-01-10 02:00:00 +0000 148209 9.03
+```console
+$ otava analyze local.sample --since=2026-01-01T00:00:00Z
+INFO: Computing change points for test local.sample...
+time commit metric1 metric2
+------------------------- -------- --------- ---------
+2026-01-01 02:00:00 +0000 aaa0 154023 10.43
+2026-01-02 02:00:00 +0000 aaa1 138455 10.23
+2026-01-03 02:00:00 +0000 aaa2 143112 10.29
+2026-01-04 02:00:00 +0000 aaa3 149190 10.91
+2026-01-05 02:00:00 +0000 aaa4 132098 10.34
+2026-01-06 02:00:00 +0000 aaa5 151344 10.69
+ ·········
+ -12.9%
+ ·········
+2026-01-07 02:00:00 +0000 aaa6 155145 9.23
+2026-01-08 02:00:00 +0000 aaa7 148889 9.11
+2026-01-09 02:00:00 +0000 aaa8 149466 9.13
+2026-01-10 02:00:00 +0000 aaa9 148209 9.03
```
## Avoiding test definition duplication
diff --git a/docs/BIG_QUERY.md b/docs/BIG_QUERY.md
index 5c0ade5..4b51ccf 100644
--- a/docs/BIG_QUERY.md
+++ b/docs/BIG_QUERY.md
@@ -19,6 +19,12 @@
# BigQuery
+## Installation
+
+```bash
+pip install 'apache-otava[bigquery]'
+```
+
## Schema
See [schema.sql](../examples/bigquery/schema.sql) for the example schema.
diff --git a/docs/CSV.md b/docs/CSV.md
index eef8013..76a91cd 100644
--- a/docs/CSV.md
+++ b/docs/CSV.md
@@ -56,25 +56,26 @@ per-test settings and defaults are unchanged.
## Example
```bash
-docker-compose -f examples/csv/docker-compose.yaml run --rm otava analyze
local.sample
+docker-compose -f examples/csv/docker-compose.yaml run --rm otava \
+ analyze local.sample --since=2026-01-01T00:00:00Z
```
Expected output:
-```bash
+```text
time commit metric1 metric2
------------------------- -------- --------- ---------
-2024-01-01 02:00:00 +0000 aaa0 154023 10.43
-2024-01-02 02:00:00 +0000 aaa1 138455 10.23
-2024-01-03 02:00:00 +0000 aaa2 143112 10.29
-2024-01-04 02:00:00 +0000 aaa3 149190 10.91
-2024-01-05 02:00:00 +0000 aaa4 132098 10.34
-2024-01-06 02:00:00 +0000 aaa5 151344 10.69
+2026-01-01 02:00:00 +0000 aaa0 154023 10.43
+2026-01-02 02:00:00 +0000 aaa1 138455 10.23
+2026-01-03 02:00:00 +0000 aaa2 143112 10.29
+2026-01-04 02:00:00 +0000 aaa3 149190 10.91
+2026-01-05 02:00:00 +0000 aaa4 132098 10.34
+2026-01-06 02:00:00 +0000 aaa5 151344 10.69
·········
-12.9%
·········
-2024-01-07 02:00:00 +0000 aaa6 155145 9.23
-2024-01-08 02:00:00 +0000 aaa7 148889 9.11
-2024-01-09 02:00:00 +0000 aaa8 149466 9.13
-2024-01-10 02:00:00 +0000 aaa9 148209 9.03
+2026-01-07 02:00:00 +0000 aaa6 155145 9.23
+2026-01-08 02:00:00 +0000 aaa7 148889 9.11
+2026-01-09 02:00:00 +0000 aaa8 149466 9.13
+2026-01-10 02:00:00 +0000 aaa9 148209 9.03
```
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
index 5a5b145..20c4a33 100644
--- a/docs/GETTING_STARTED.md
+++ b/docs/GETTING_STARTED.md
@@ -27,16 +27,36 @@ Otava requires Python 3.10 or later.
pip install apache-otava
```
+This installs the Otava library and CLI with CSV, JSON, and Graphite
+support. Install the extra for any additional service you use, for example:
+
+```bash
+pip install 'apache-otava[postgres]'
+pip install 'apache-otava[bigquery,slack]'
+```
+
+See [Installation](INSTALL.md) for the complete list of extras.
+
or
```bash
docker pull apache/otava
```
+The Docker image includes all optional integrations.
+
## Setup
-Copy the main configuration file `resources/otava.yaml` to
`~/.otava/otava.yaml` and adjust data source configuration.
+By default, Otava reads configuration from `~/.otava/otava.yaml`. Create that
+file and add the data sources and tests you want to analyze.
+
+To run the bundled CSV example from a source checkout, use its local
+configuration while running the commands in this guide:
+
+```bash
+export OTAVA_CONFIG=examples/csv/config/otava-local.yaml
+```
> [!TIP]
> See docs on specific data sources to learn more about their configuration -
> [CSV](CSV.md), [Graphite](GRAPHITE.md),
@@ -132,23 +152,22 @@ The results are simply concatenated.
## Example
-```
-$ otava analyze local.sample
-INFO: Computing change points for test sample.csv...
-sample:
-time metric1 metric2
-------------------------- --------- ---------
-2021-01-01 02:00:00 +0000 154023 10.43
-2021-01-02 02:00:00 +0000 138455 10.23
-2021-01-03 02:00:00 +0000 143112 10.29
-2021-01-04 02:00:00 +0000 149190 10.91
-2021-01-05 02:00:00 +0000 132098 10.34
-2021-01-06 02:00:00 +0000 151344 10.69
- ·········
- -12.9%
- ·········
-2021-01-07 02:00:00 +0000 155145 9.23
-2021-01-08 02:00:00 +0000 148889 9.11
-2021-01-09 02:00:00 +0000 149466 9.13
-2021-01-10 02:00:00 +0000 148209 9.03
+```console
+$ otava analyze local.sample --since=2026-01-01T00:00:00Z
+INFO: Computing change points for test local.sample...
+time commit metric1 metric2
+------------------------- -------- --------- ---------
+2026-01-01 02:00:00 +0000 aaa0 154023 10.43
+2026-01-02 02:00:00 +0000 aaa1 138455 10.23
+2026-01-03 02:00:00 +0000 aaa2 143112 10.29
+2026-01-04 02:00:00 +0000 aaa3 149190 10.91
+2026-01-05 02:00:00 +0000 aaa4 132098 10.34
+2026-01-06 02:00:00 +0000 aaa5 151344 10.69
+ ·········
+ -12.9%
+ ·········
+2026-01-07 02:00:00 +0000 aaa6 155145 9.23
+2026-01-08 02:00:00 +0000 aaa7 148889 9.11
+2026-01-09 02:00:00 +0000 aaa8 149466 9.13
+2026-01-10 02:00:00 +0000 aaa9 148209 9.03
```
diff --git a/docs/GRAFANA.md b/docs/GRAFANA.md
index 4d5fb0d..824eeb4 100644
--- a/docs/GRAFANA.md
+++ b/docs/GRAFANA.md
@@ -19,6 +19,14 @@
# Annotating Change Points in Grafana
+## Installation
+
+```bash
+pip install 'apache-otava[grafana]'
+```
+
+## Usage
+
Change points found by `analyze` can be exported
as Grafana annotations using the `--update-grafana` flag:
@@ -55,8 +63,7 @@ Start docker-compose with Graphite in one tab:
```bash
docker-compose -f examples/graphite/docker-compose.yaml up --force-recreate
--always-recreate-deps --renew-anon-volumes
-````
-
+```
Run otava in another tab:
@@ -64,9 +71,9 @@ Run otava in another tab:
docker-compose -f examples/graphite/docker-compose.yaml run --rm otava
analyze my-product.test --since=-10m --update-grafana
```
-Expected output:
+Example output (timestamps reflect when the example is run):
-```bash
+```text
time run branch version commit throughput
response_time cpu_usage
------------------------- ----- -------- --------- -------- ------------
--------------- -----------
2024-12-14 22:45:10 +0000 61160
87 0.2
diff --git a/docs/GRAPHITE.md b/docs/GRAPHITE.md
index 2f01129..abc1302 100644
--- a/docs/GRAPHITE.md
+++ b/docs/GRAPHITE.md
@@ -100,7 +100,7 @@ Start docker-compose with Graphite in one tab:
```bash
docker-compose -f examples/graphite/docker-compose.yaml up --force-recreate
--always-recreate-deps --renew-anon-volumes
-````
+```
Run otava in another tab:
@@ -108,9 +108,9 @@ Run otava in another tab:
docker-compose -f examples/graphite/docker-compose.yaml run --rm otava analyze
my-product.test --since=-10m
```
-Expected output:
+Example output (timestamps reflect when the example is run):
-```bash
+```text
time run branch version commit throughput
response_time cpu_usage
------------------------- ----- -------- --------- -------- ------------
--------------- -----------
2024-12-14 22:45:10 +0000 61160
87 0.2
diff --git a/docs/INFLUXDB.md b/docs/INFLUXDB.md
index 024131f..fd8c31a 100644
--- a/docs/INFLUXDB.md
+++ b/docs/INFLUXDB.md
@@ -24,6 +24,12 @@ Otava imports query results from InfluxDB 3 Core or
Enterprise through the
client. SQL is the default query language; set `query_language: influxql` for
InfluxQL queries.
+## Installation
+
+```bash
+pip install 'apache-otava[influxdb]'
+```
+
## Connection
```yaml
@@ -46,7 +52,7 @@ 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
+ analyze api_latency_sql --branch main --since 2025-01-01T00:00:00Z
docker compose -f examples/influxdb/docker-compose.yaml down
```
@@ -92,7 +98,7 @@ when `--branch` is supplied.
Run the analysis with:
```bash
-otava analyze api_latency_sql --branch main --last 100
+otava analyze api_latency_sql --branch main --since 2025-01-01T00:00:00Z
--last 100
```
InfluxDB is import-only in this release; Otava does not write change points
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index 1844f89..c4c1362 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -27,6 +27,26 @@ Otava requires Python 3.10 or later.
pip install apache-otava
```
+The default installation includes the Otava library and CLI, with support for
CSV,
+JSON, and Graphite data. Install an extra when you need another service:
+
+| Extra | Adds support for |
+| --- | --- |
+| `bigquery` | Google BigQuery |
+| `postgres` | PostgreSQL |
+| `influxdb` | InfluxDB 3 |
+| `grafana` | Grafana annotations |
+| `slack` | Slack notifications |
+| `all` | All optional integrations |
+
+Extras are additive. For example:
+
+```bash
+pip install 'apache-otava[bigquery]'
+pip install 'apache-otava[bigquery,postgres]'
+pip install 'apache-otava[all]'
+```
+
## Install using Docker
Pull the official Docker image:
@@ -34,3 +54,5 @@ Pull the official Docker image:
```bash
docker pull apache/otava
```
+
+The Docker image includes all optional integrations.
diff --git a/docs/POSTGRESQL.md b/docs/POSTGRESQL.md
index 870aa5d..ba6ced0 100644
--- a/docs/POSTGRESQL.md
+++ b/docs/POSTGRESQL.md
@@ -22,6 +22,12 @@
> [!TIP]
> See [otava.yaml](../examples/postgresql/config/otava.yaml) for the full
> example configuration.
+## Installation
+
+```bash
+pip install 'apache-otava[postgres]'
+```
+
## PostgreSQL Connection
The following block contains PostgreSQL connection details:
@@ -94,28 +100,29 @@ Start docker-compose with PostgreSQL in one tab:
```bash
docker-compose -f examples/postgresql/docker-compose.yaml up --force-recreate
--always-recreate-deps --renew-anon-volumes
-````
+```
Run Otava in the other tab to show results for a single test `aggregate_mem`
and update the database with newly found change points:
```bash
-docker-compose -f examples/postgresql/docker-compose.yaml run --rm otava
analyze aggregate_mem --update-postgres
+docker-compose -f examples/postgresql/docker-compose.yaml run --rm otava \
+ analyze aggregate_mem --since=2025-01-01 --update-postgres
```
Expected output:
-```bash
0.0s
-time experiment_id commit
process_cumulative_rate_mean process_cumulative_rate_stderr
process_cumulative_rate_diff
-------------------------- ------------------ --------
------------------------------ --------------------------------
------------------------------
-2024-03-13 10:03:02 +0000 aggregate-36e5ccd2 36e5ccd2
61160 2052 13558
-2024-03-25 10:03:02 +0000 aggregate-d5460f38 d5460f38
60160 2142 13454
-2024-04-02 10:03:02 +0000 aggregate-bc9425cb bc9425cb
60960 2052 13053
-
······························
-
-5.6%
-
······························
-2024-04-06 10:03:02 +0000 aggregate-14df1b11 14df1b11
57123 2052 14052
-2024-04-13 10:03:02 +0000 aggregate-ac40c0d8 ac40c0d8
57980 2052 13521
-2024-04-27 10:03:02 +0000 aggregate-0af4ccbc 0af4ccbc
56950 2052 13532
+```text
+time experiment_id commit config_id
process_cumulative_rate_mean process_cumulative_rate_stderr
process_cumulative_rate_diff
+------------------------- ------------------ -------- -----------
------------------------------ --------------------------------
------------------------------
+2025-03-13 10:03:02 +0000 aggregate-36e5ccd2 36e5ccd2 1
61160 2052
13558
+2025-03-25 10:03:02 +0000 aggregate-d5460f38 d5460f38 1
60160 2142
13454
+2025-04-02 10:03:02 +0000 aggregate-bc9425cb bc9425cb 1
60960 2052
13053
+
······························
+
-5.6%
+
······························
+2025-04-06 10:03:02 +0000 aggregate-14df1b11 14df1b11 1
57123 2052
14052
+2025-04-13 10:03:02 +0000 aggregate-ac40c0d8 ac40c0d8 1
57980 2052
13521
+2025-04-27 10:03:02 +0000 aggregate-0af4ccbc 0af4ccbc 1
56950 2052
13532
```
### Configuration
diff --git a/examples/csv/config/otava-local.yaml
b/examples/csv/config/otava-local.yaml
new file mode 100644
index 0000000..0416db0
--- /dev/null
+++ b/examples/csv/config/otava-local.yaml
@@ -0,0 +1,27 @@
+# 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.
+
+tests:
+ local.sample:
+ type: csv
+ file: examples/csv/data/local_sample.csv
+ time_column: time
+ attributes: [commit]
+ metrics: [metric1, metric2]
+ csv_options:
+ delimiter: ","
+ quote_char: "'"
diff --git a/otava/_optional.py b/otava/_optional.py
new file mode 100644
index 0000000..3fdafd9
--- /dev/null
+++ b/otava/_optional.py
@@ -0,0 +1,39 @@
+# 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 importlib import import_module
+from types import ModuleType
+
+
+class MissingOptionalDependencyError(ModuleNotFoundError):
+ pass
+
+
+def import_optional_dependency(module_name: str, extra_name: str) ->
ModuleType:
+ try:
+ return import_module(module_name)
+ except ModuleNotFoundError as err:
+ missing_module = err.name
+ if missing_module and (
+ module_name == missing_module or
module_name.startswith(f"{missing_module}.")
+ ):
+ raise MissingOptionalDependencyError(
+ f"Optional dependency '{module_name}' is required for this
operation. "
+ f"Install it with: pip install 'apache-otava[{extra_name}]'",
+ name=module_name,
+ ) from err
+ raise
diff --git a/otava/bigquery.py b/otava/bigquery.py
index 8ceb5a5..4708863 100644
--- a/otava/bigquery.py
+++ b/otava/bigquery.py
@@ -15,17 +15,31 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
from dataclasses import dataclass
from datetime import datetime
-from typing import Dict, List, Optional
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
-from google.cloud import bigquery
-from google.oauth2 import service_account
+if TYPE_CHECKING:
+ from google.cloud.bigquery import Client, ScalarQueryParameter
+else:
+ Client = Any
+ ScalarQueryParameter = Any
+from otava._optional import import_optional_dependency
from otava.change_point_divisive.base import ChangePointGroup,
ChangePointSerializer
from otava.test_config import BigQueryTestConfig
+def _bigquery_module():
+ return import_optional_dependency("google.cloud.bigquery", "bigquery")
+
+
+def _service_account_module():
+ return import_optional_dependency("google.oauth2.service_account",
"bigquery")
+
+
@dataclass
class BigQueryConfig:
NAME = "bigquery"
@@ -62,8 +76,10 @@ class BigQuery:
self.__config = config
@property
- def client(self) -> bigquery.Client:
+ def client(self) -> Client:
if self.__client is None:
+ bigquery = _bigquery_module()
+ service_account = _service_account_module()
credentials =
service_account.Credentials.from_service_account_file(
self.__config.credentials,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
@@ -72,8 +88,9 @@ class BigQuery:
return self.__client
def fetch_data(
- self, query: str, params:
Optional[List[bigquery.ScalarQueryParameter]] = None
+ self, query: str, params: Optional[List[ScalarQueryParameter]] = None
):
+ bigquery = _bigquery_module()
job_config = None
if params:
job_config = bigquery.QueryJobConfig(query_parameters=params)
diff --git a/otava/grafana.py b/otava/grafana.py
index b853f3a..ea0e7fa 100644
--- a/otava/grafana.py
+++ b/otava/grafana.py
@@ -19,9 +19,13 @@ from dataclasses import asdict, dataclass
from datetime import datetime
from typing import List, Optional
-import requests
from pytz import UTC
-from requests.exceptions import HTTPError
+
+from otava._optional import import_optional_dependency
+
+
+def _requests_module():
+ return import_optional_dependency("requests", "grafana")
@dataclass
@@ -77,6 +81,7 @@ class Grafana:
Reference:
-
https://grafana.com/docs/grafana/latest/http_api/annotations/#find-annotations
"""
+ requests = _requests_module()
url = f"{self.url}api/annotations"
query_parameters = {}
if start is not None:
@@ -105,7 +110,7 @@ class Grafana:
except KeyError as err:
raise GrafanaError(f"Missing field {err.args[0]}")
- except HTTPError as err:
+ except requests.exceptions.HTTPError as err:
raise GrafanaError(str(err))
def delete_annotations(self, *ids: int):
@@ -113,13 +118,14 @@ class Grafana:
Reference:
-
https://grafana.com/docs/grafana/latest/http_api/annotations/#delete-annotation-by-id
"""
+ requests = _requests_module()
url = f"{self.url}api/annotations"
for annotation_id in ids:
annotation_url = f"{url}/{annotation_id}"
try:
response = requests.delete(url=annotation_url,
auth=(self.__user, self.__password))
response.raise_for_status()
- except HTTPError as err:
+ except requests.exceptions.HTTPError as err:
raise GrafanaError(str(err))
def create_annotations(self, *annotations: Annotation):
@@ -127,6 +133,7 @@ class Grafana:
Reference:
-
https://grafana.com/docs/grafana/latest/http_api/annotations/#create-annotation
"""
+ requests = _requests_module()
try:
url = f"{self.url}api/annotations"
for annotation in annotations:
@@ -135,5 +142,5 @@ class Grafana:
del data["id"]
response = requests.post(url=url, json=data,
auth=(self.__user, self.__password))
response.raise_for_status()
- except HTTPError as err:
+ except requests.exceptions.HTTPError as err:
raise GrafanaError(str(err))
diff --git a/otava/importer.py b/otava/importer.py
index 0552405..a0e31a4 100644
--- a/otava/importer.py
+++ b/otava/importer.py
@@ -24,9 +24,8 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Optional, Set
-from google.cloud import bigquery
-
-from otava.bigquery import BigQuery
+from otava._optional import MissingOptionalDependencyError
+from otava.bigquery import BigQuery, _bigquery_module
from otava.config import Config
from otava.data_selector import DataSelector
from otava.graphite import DataPoint, Graphite, GraphiteError
@@ -758,7 +757,9 @@ class BigQueryImporter(Importer):
)
# Replace placeholder with @branch for BigQuery parameterized query
query = query.replace("%{BRANCH}", "@branch")
- params = [bigquery.ScalarQueryParameter("branch", "STRING",
selector.branch)]
+ params = [
+ _bigquery_module().ScalarQueryParameter("branch", "STRING",
selector.branch)
+ ]
columns, rows = self.__bigquery.fetch_data(query, params)
@@ -875,6 +876,8 @@ class InfluxDBImporter(Importer):
try:
columns, rows = self.__influxdb.fetch_data(query,
test_conf.query_language)
+ except MissingOptionalDependencyError:
+ raise
except Exception as err:
raise DataImportError(f"Failed to import test {test_conf.name}:
{err}") from err
diff --git a/otava/influxdb.py b/otava/influxdb.py
index af5a53b..c4aa450 100644
--- a/otava/influxdb.py
+++ b/otava/influxdb.py
@@ -15,9 +15,22 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from influxdb_client_3 import InfluxDBClient3
+else:
+ InfluxDBClient3 = Any
+
+from otava._optional import import_optional_dependency
+
-from influxdb_client_3 import InfluxDBClient3
+def _influxdb_client_class():
+ influxdb = import_optional_dependency("influxdb_client_3", "influxdb")
+ return influxdb.InfluxDBClient3
@dataclass
@@ -51,7 +64,7 @@ class InfluxDB:
@property
def client(self) -> InfluxDBClient3:
if self._client is None:
- self._client = InfluxDBClient3(
+ self._client = _influxdb_client_class()(
host=self.config.host,
database=self.config.database,
token=self.config.token,
diff --git a/otava/main.py b/otava/main.py
index 3bb164d..395061f 100644
--- a/otava/main.py
+++ b/otava/main.py
@@ -22,9 +22,9 @@ from typing import Dict, List, Optional
import configargparse as argparse
import pytz
-from slack_sdk import WebClient
from otava import config
+from otava._optional import MissingOptionalDependencyError
from otava.attributes import get_back_links
from otava.bigquery import BigQuery, BigQueryError
from otava.config import Config
@@ -35,7 +35,7 @@ from otava.importer import DataImportError, Importers
from otava.postgres import Postgres, PostgresError
from otava.report import Report, ReportType
from otava.series import AnalysisOptions, AnalyzedSeries
-from otava.slack import NotificationError, SlackNotifier
+from otava.slack import NotificationError, SlackNotifier,
_create_slack_notifier
from otava.test_config import (
BigQueryTestConfig,
GraphiteTestConfig,
@@ -63,7 +63,7 @@ class Otava:
self.__conf = conf
self.__importers = Importers(conf)
self.__grafana = None
- self.__slack = self.__maybe_create_slack_notifier()
+ self.__slack = None
self.__postgres = None
self.__bigquery = None
@@ -251,10 +251,12 @@ class Otava:
for cpg in cpg_list:
bigquery.insert_change_point(test, metric_name,
cpg.attributes, cpg)
- def __maybe_create_slack_notifier(self):
- if not self.__conf.slack:
+ def __get_slack_notifier(self):
+ if not self.__conf.slack or not self.__conf.slack.bot_token:
return None
- return SlackNotifier(WebClient(token=self.__conf.slack.bot_token))
+ if self.__slack is None:
+ self.__slack = _create_slack_notifier(self.__conf.slack.bot_token)
+ return self.__slack
def notify_slack(
self,
@@ -263,12 +265,13 @@ class Otava:
channels: List[str],
since: datetime,
):
- if not self.__slack:
+ slack = self.__get_slack_notifier()
+ if not slack:
logging.error(
"Slack definition is missing from the configuration, cannot
send notification"
)
return
- self.__slack.notify(test_change_points, selector=selector,
channels=channels, since=since)
+ slack.notify(test_change_points, selector=selector, channels=channels,
since=since)
def validate(self):
valid = True
@@ -644,6 +647,9 @@ def script_main(conf: Config = None, args: List[str] =
None):
except NotificationError as err:
logging.error(err.message)
exit(1)
+ except MissingOptionalDependencyError as err:
+ logging.error(str(err))
+ exit(1)
def main():
diff --git a/otava/postgres.py b/otava/postgres.py
index cbf22af..d00631d 100644
--- a/otava/postgres.py
+++ b/otava/postgres.py
@@ -15,16 +15,26 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
from dataclasses import dataclass
from datetime import datetime
-from typing import Dict
+from typing import TYPE_CHECKING, Any, Dict
-import pg8000
+if TYPE_CHECKING:
+ from pg8000.dbapi import Connection
+else:
+ Connection = Any
+from otava._optional import import_optional_dependency
from otava.change_point_divisive.base import ChangePointGroup,
ChangePointSerializer
from otava.test_config import PostgresTestConfig
+def _pg8000_module():
+ return import_optional_dependency("pg8000", "postgres")
+
+
@dataclass
class PostgresConfig:
NAME = "postgres"
@@ -66,8 +76,9 @@ class Postgres:
def __init__(self, config: PostgresConfig):
self.__config = config
- def __get_conn(self) -> pg8000.dbapi.Connection:
+ def __get_conn(self) -> Connection:
if self.__conn is None:
+ pg8000 = _pg8000_module()
self.__conn = pg8000.dbapi.Connection(
host=self.__config.hostname,
port=self.__config.port,
diff --git a/otava/slack.py b/otava/slack.py
index 441f083..22ee8b5 100644
--- a/otava/slack.py
+++ b/otava/slack.py
@@ -15,14 +15,21 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
from dataclasses import dataclass
from datetime import datetime
from math import isinf
-from typing import Dict, List
+from typing import TYPE_CHECKING, Any, Dict, List
from pytz import UTC
-from slack_sdk import WebClient
+if TYPE_CHECKING:
+ from slack_sdk import WebClient
+else:
+ WebClient = Any
+
+from otava._optional import import_optional_dependency
from otava.change_point_divisive.base import ChangePointGroup,
ChangePointSerializer
from otava.data_selector import DataSelector
from otava.series import AnalyzedSeries
@@ -263,3 +270,8 @@ class SlackNotifier:
for channel in channels:
for blocks in dispatches:
self.__client.chat_postMessage(channel=channel, blocks=blocks)
+
+
+def _create_slack_notifier(token: str) -> SlackNotifier:
+ slack_sdk = import_optional_dependency("slack_sdk", "slack")
+ return SlackNotifier(slack_sdk.WebClient(token=token))
diff --git a/pyproject.toml b/pyproject.toml
index 53e3762..629a247 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,7 @@ classifiers = [
]
dependencies = [
"dateparser>=1.0.0",
+ "pytz==2025.2",
# NumPy 2.2.x supports Python 3.10–3.13, while Python 3.14 requires a
# newer NumPy release to get prebuilt wheels on all supported platforms.
@@ -46,14 +47,9 @@ dependencies = [
"python-dateutil>=2.9.0",
"ruamel.yaml==0.18.16",
- "requests>=2.32.5",
"pystache>=0.6.8",
"tabulate>=0.9.0",
"validators>=0.35.0",
- "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",
@@ -65,17 +61,27 @@ dependencies = [
]
[project.optional-dependencies]
-dev = [
- "hypothesis>=6.0",
- "pytest>=9.0.1",
- "pytest-benchmark>=5.2.3",
- "pytz==2025.2",
- "tox==4.32.0",
- "flake8>=7.3.0",
- "autoflake>=2.3.1",
- "isort>=7.0.0",
- "ruff>=0.14.8",
- "pre-commit==4.5.0",
+bigquery = [
+ "google-cloud-bigquery>=3.38.0",
+]
+postgres = [
+ "pg8000>=1.31.5",
+]
+influxdb = [
+ "influxdb3-python>=0.20.0",
+]
+grafana = [
+ "requests>=2.32.5",
+]
+slack = [
+ "slack-sdk>=3.39.0",
+]
+all = [
+ "google-cloud-bigquery>=3.38.0",
+ "pg8000>=1.31.5",
+ "influxdb3-python>=0.20.0",
+ "requests>=2.32.5",
+ "slack-sdk>=3.39.0",
]
[project.scripts]
@@ -118,5 +124,14 @@ profile = "black"
[dependency-groups]
dev = [
+ "hypothesis>=6.0",
+ "pytest>=9.0.1",
+ "pytest-benchmark>=5.2.3",
"pytest-cov>=7.1.0",
+ "tox==4.32.0",
+ "flake8>=7.3.0",
+ "autoflake>=2.3.1",
+ "isort>=7.0.0",
+ "ruff>=0.14.8",
+ "pre-commit==4.5.0",
]
diff --git a/tests/core_install_smoke.py b/tests/core_install_smoke.py
new file mode 100644
index 0000000..72b300d
--- /dev/null
+++ b/tests/core_install_smoke.py
@@ -0,0 +1,130 @@
+# 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 csv
+import os
+import subprocess
+import sys
+import tempfile
+from importlib import metadata
+from pathlib import Path
+
+from otava.bigquery import BigQuery, BigQueryConfig
+from otava.grafana import Grafana, GrafanaConfig
+from otava.influxdb import InfluxDB, InfluxDBConfig
+from otava.postgres import Postgres, PostgresConfig
+from otava.slack import _create_slack_notifier
+
+OPTIONAL_DISTRIBUTIONS = {
+ "google-cloud-bigquery": "bigquery",
+ "pg8000": "postgres",
+ "influxdb3-python": "influxdb",
+ "requests": "grafana",
+ "slack-sdk": "slack",
+}
+
+
+def assert_optional_distributions_are_absent():
+ for distribution in OPTIONAL_DISTRIBUTIONS:
+ try:
+ metadata.version(distribution)
+ except metadata.PackageNotFoundError:
+ continue
+ raise AssertionError(f"{distribution} was installed by the default
package")
+
+
+def assert_cli_help_works():
+ result = subprocess.run(
+ [sys.executable, "-m", "otava.main", "--help"], capture_output=True,
text=True
+ )
+ assert result.returncode == 0, result.stderr
+ assert "usage:" in result.stdout
+
+
+def assert_csv_analysis_works():
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ data_dir = root / "data"
+ data_dir.mkdir()
+ with (data_dir / "sample.csv").open("w", newline="") as csv_file:
+ writer = csv.writer(csv_file)
+ writer.writerow(["time", "metric"])
+ for day, value in enumerate([10, 11, 9, 10, 30, 31, 29, 30],
start=1):
+ writer.writerow([f"2026-01-{day:02d}T00:00:00+00:00", value])
+
+ config = root / "otava.yaml"
+ config.write_text(
+ """tests:
+ local.sample:
+ type: csv
+ file: data/sample.csv
+ time_column: time
+ metrics: [metric]
+""",
+ encoding="utf-8",
+ )
+
+ result = subprocess.run(
+ [sys.executable, "-m", "otava.main", "analyze", "local.sample"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ env={**os.environ, "OTAVA_CONFIG": str(config)},
+ )
+ assert result.returncode == 0, result.stderr
+ assert "metric" in result.stdout
+
+
+def assert_missing_extra(extra_name, operation):
+ try:
+ operation()
+ except ModuleNotFoundError as err:
+ expected = f"pip install 'apache-otava[{extra_name}]'"
+ assert expected in str(err), str(err)
+ return
+ raise AssertionError(f"{extra_name} operation did not report its missing
extra")
+
+
+def assert_optional_operations_name_their_extras():
+ operations = {
+ "bigquery": lambda: BigQuery(
+ BigQueryConfig("project", "dataset", "credentials.json")
+ ).client,
+ "postgres": lambda: Postgres(
+ PostgresConfig("localhost", 5432, "user", "password", "database")
+ ).fetch_data("SELECT 1"),
+ "influxdb": lambda: InfluxDB(
+ InfluxDBConfig("http://localhost:8181", "database", "token")
+ ).client,
+ "grafana": lambda: Grafana(
+ GrafanaConfig("https://example.invalid/", "user", "password")
+ ).fetch_annotations(None, None),
+ "slack": lambda: _create_slack_notifier("token"),
+ }
+ for extra_name, operation in operations.items():
+ assert_missing_extra(extra_name, operation)
+
+
+def main():
+ assert_optional_distributions_are_absent()
+ assert_cli_help_works()
+ assert_csv_analysis_works()
+ assert_optional_operations_name_their_extras()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/csv_e2e_test.py b/tests/csv_e2e_test.py
index 25c90a3..14fcdde 100644
--- a/tests/csv_e2e_test.py
+++ b/tests/csv_e2e_test.py
@@ -18,6 +18,7 @@
import csv
import os
import subprocess
+import sys
import tempfile
import textwrap
from datetime import datetime, timedelta, timezone
@@ -27,6 +28,50 @@ import pytest
from e2e_test_utils import _remove_trailing_whitespaces
+def test_bundled_csv_example():
+ expected_output = textwrap.dedent(
+ """\
+ time commit metric1 metric2
+ ------------------------- -------- --------- ---------
+ 2026-01-01 02:00:00 +0000 aaa0 154023 10.43
+ 2026-01-02 02:00:00 +0000 aaa1 138455 10.23
+ 2026-01-03 02:00:00 +0000 aaa2 143112 10.29
+ 2026-01-04 02:00:00 +0000 aaa3 149190 10.91
+ 2026-01-05 02:00:00 +0000 aaa4 132098 10.34
+ 2026-01-06 02:00:00 +0000 aaa5 151344 10.69
+ ·········
+ -12.9%
+ ·········
+ 2026-01-07 02:00:00 +0000 aaa6 155145 9.23
+ 2026-01-08 02:00:00 +0000 aaa7 148889 9.11
+ 2026-01-09 02:00:00 +0000 aaa8 149466 9.13
+ 2026-01-10 02:00:00 +0000 aaa9 148209 9.03
+ """
+ )
+ repo_root = Path(__file__).parents[1]
+ config = repo_root / "examples/csv/config/otava-local.yaml"
+ cmd = [
+ sys.executable,
+ "-m",
+ "otava.main",
+ "analyze",
+ "local.sample",
+ "--since=2026-01-01T00:00:00Z",
+ ]
+
+ proc = subprocess.run(
+ cmd,
+ cwd=repo_root,
+ env=dict(os.environ, OTAVA_CONFIG=str(config)),
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+
+ assert proc.returncode == 0, proc.stderr
+ assert _remove_trailing_whitespaces(proc.stdout) ==
expected_output.rstrip("\n")
+
+
def test_analyze_csv():
"""
End-to-end test for the CSV example from docs/CSV.md.
diff --git a/tests/optional_dependencies_test.py
b/tests/optional_dependencies_test.py
new file mode 100644
index 0000000..f338dff
--- /dev/null
+++ b/tests/optional_dependencies_test.py
@@ -0,0 +1,169 @@
+# 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 subprocess
+import sys
+import textwrap
+from importlib import import_module, metadata
+from pathlib import Path
+
+import pytest
+
+
+def test_development_dependencies_are_not_published_as_an_extra():
+ published_extras =
metadata.metadata("apache-otava").get_all("Provides-Extra") or []
+
+ assert "dev" not in published_extras
+
+
+def test_pytz_is_declared_as_a_runtime_dependency():
+ requirements = metadata.requires("apache-otava") or []
+
+ assert any(
+ requirement.lower().startswith("pytz") and "extra ==" not in
requirement
+ for requirement in requirements
+ )
+
+
+def test_missing_optional_dependency_names_install_extra(monkeypatch):
+ optional = import_module("otava._optional")
+
+ def missing_google(_):
+ raise ModuleNotFoundError("No module named 'google'", name="google")
+
+ monkeypatch.setattr(optional, "import_module", missing_google)
+
+ with pytest.raises(ModuleNotFoundError) as exc_info:
+ optional.import_optional_dependency("google.cloud.bigquery",
"bigquery")
+
+ assert str(exc_info.value) == (
+ "Optional dependency 'google.cloud.bigquery' is required for this
operation. "
+ "Install it with: pip install 'apache-otava[bigquery]'"
+ )
+
+
+def test_optional_dependency_preserves_unrelated_import_failure(monkeypatch):
+ optional = import_module("otava._optional")
+ original_error = ModuleNotFoundError("No module named
'transitive_package'", name="transitive_package")
+
+ def missing_transitive(_):
+ raise original_error
+
+ monkeypatch.setattr(optional, "import_module", missing_transitive)
+
+ with pytest.raises(ModuleNotFoundError) as exc_info:
+ optional.import_optional_dependency("google.cloud.bigquery",
"bigquery")
+
+ assert exc_info.value is original_error
+
+
+def test_cli_import_does_not_load_optional_service_clients():
+ script = textwrap.dedent(
+ """
+ import builtins
+
+ blocked = {"google", "influxdb_client_3", "pg8000", "requests",
"slack_sdk"}
+ original_import = builtins.__import__
+
+ def import_without_optional_clients(name, globals=None, locals=None,
fromlist=(), level=0):
+ top_level = name.partition(".")[0]
+ if top_level in blocked:
+ raise ModuleNotFoundError(f"No module named '{top_level}'",
name=top_level)
+ return original_import(name, globals, locals, fromlist, level)
+
+ builtins.__import__ = import_without_optional_clients
+
+ from otava.main import create_otava_cli_parser
+
+ assert "usage:" in create_otava_cli_parser().format_help()
+ """
+ )
+
+ result = subprocess.run([sys.executable, "-c", script],
capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+
+
+def test_runtime_type_hints_do_not_require_optional_service_clients():
+ script = textwrap.dedent(
+ """
+ import builtins
+ from typing import get_type_hints
+
+ blocked = {"google", "influxdb_client_3", "pg8000", "slack_sdk"}
+ original_import = builtins.__import__
+
+ def import_without_optional_clients(name, globals=None, locals=None,
fromlist=(), level=0):
+ top_level = name.partition(".")[0]
+ if top_level in blocked:
+ raise ModuleNotFoundError(f"No module named '{top_level}'",
name=top_level)
+ return original_import(name, globals, locals, fromlist, level)
+
+ builtins.__import__ = import_without_optional_clients
+
+ from otava.bigquery import BigQuery
+ from otava.influxdb import InfluxDB
+ from otava.postgres import Postgres
+ from otava.slack import SlackNotifier
+
+ get_type_hints(BigQuery.client.fget)
+ get_type_hints(InfluxDB.client.fget)
+ get_type_hints(Postgres._Postgres__get_conn)
+ get_type_hints(SlackNotifier.__init__)
+ """
+ )
+
+ result = subprocess.run([sys.executable, "-c", script],
capture_output=True, text=True)
+
+ assert result.returncode == 0, result.stderr
+
+
+def test_missing_influxdb_extra_causes_cli_failure(monkeypatch, caplog):
+ optional = import_module("otava._optional")
+
+ def missing_influxdb(module_name):
+ assert module_name == "influxdb_client_3"
+ raise ModuleNotFoundError(
+ "No module named 'influxdb_client_3'",
+ name="influxdb_client_3",
+ )
+
+ monkeypatch.setattr(optional, "import_module", missing_influxdb)
+
+ from otava.main import script_main
+
+ config = Path(__file__).parents[1] / "examples/influxdb/otava.yaml"
+ args = [
+ "analyze",
+ "--config-file",
+ str(config),
+ "api_latency_sql",
+ "--branch",
+ "main",
+ "--influxdb-host",
+ "http://localhost:8181",
+ "--influxdb-database",
+ "performance",
+ "--influxdb-token",
+ "token",
+ ]
+
+ with pytest.raises(SystemExit) as exc_info:
+ script_main(args=args)
+
+ assert exc_info.value.code == 1
+ assert "pip install 'apache-otava[influxdb]'" in caplog.text
diff --git a/uv.lock b/uv.lock
index 9b08a4e..dd89904 100644
--- a/uv.lock
+++ b/uv.lock
@@ -24,24 +24,44 @@ source = { editable = "." }
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" },
{ name = "pydantic" },
{ name = "pystache" },
{ name = "python-dateutil" },
- { name = "requests" },
+ { name = "pytz" },
{ name = "ruamel-yaml" },
{ name = "scipy", version = "1.15.3", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.16.3", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
- { name = "slack-sdk" },
{ name = "tabulate" },
{ name = "validators" },
]
[package.optional-dependencies]
+all = [
+ { name = "google-cloud-bigquery" },
+ { name = "influxdb3-python" },
+ { name = "pg8000" },
+ { name = "requests" },
+ { name = "slack-sdk" },
+]
+bigquery = [
+ { name = "google-cloud-bigquery" },
+]
+grafana = [
+ { name = "requests" },
+]
+influxdb = [
+ { name = "influxdb3-python" },
+]
+postgres = [
+ { name = "pg8000" },
+]
+slack = [
+ { name = "slack-sdk" },
+]
+
+[package.dev-dependencies]
dev = [
{ name = "autoflake" },
{ name = "flake8" },
@@ -50,50 +70,52 @@ dev = [
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-benchmark" },
- { name = "pytz" },
+ { name = "pytest-cov" },
{ name = "ruff" },
{ name = "tox" },
]
-[package.dev-dependencies]
-dev = [
- { name = "pytest-cov" },
-]
-
[package.metadata]
requires-dist = [
- { name = "autoflake", marker = "extra == 'dev'", specifier = ">=2.3.1" },
{ name = "configargparse", specifier = ">=1.7.1" },
{ name = "dateparser", specifier = ">=1.0.0" },
- { 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 = "google-cloud-bigquery", marker = "extra == 'all'", specifier =
">=3.38.0" },
+ { name = "google-cloud-bigquery", marker = "extra == 'bigquery'",
specifier = ">=3.38.0" },
+ { name = "influxdb3-python", marker = "extra == 'all'", specifier =
">=0.20.0" },
+ { name = "influxdb3-python", marker = "extra == 'influxdb'", specifier =
">=0.20.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" },
- { name = "pg8000", specifier = ">=1.31.5" },
- { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.5.0" },
+ { name = "pg8000", marker = "extra == 'all'", specifier = ">=1.31.5" },
+ { name = "pg8000", marker = "extra == 'postgres'", specifier = ">=1.31.5"
},
{ name = "pydantic", specifier = ">=2,<3" },
{ name = "pystache", specifier = ">=0.6.8" },
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.1" },
- { name = "pytest-benchmark", marker = "extra == 'dev'", specifier =
">=5.2.3" },
{ name = "python-dateutil", specifier = ">=2.9.0" },
- { name = "pytz", marker = "extra == 'dev'", specifier = "==2025.2" },
- { name = "requests", specifier = ">=2.32.5" },
+ { name = "pytz", specifier = "==2025.2" },
+ { name = "requests", marker = "extra == 'all'", specifier = ">=2.32.5" },
+ { name = "requests", marker = "extra == 'grafana'", specifier = ">=2.32.5"
},
{ name = "ruamel-yaml", specifier = "==0.18.16" },
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.8" },
{ name = "scipy", marker = "python_full_version < '3.11'", specifier =
">=1.15,<1.16" },
{ name = "scipy", marker = "python_full_version >= '3.11'", specifier =
">=1.16,<1.17" },
- { name = "slack-sdk", specifier = ">=3.39.0" },
+ { name = "slack-sdk", marker = "extra == 'all'", specifier = ">=3.39.0" },
+ { name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.39.0"
},
{ name = "tabulate", specifier = ">=0.9.0" },
- { name = "tox", marker = "extra == 'dev'", specifier = "==4.32.0" },
{ name = "validators", specifier = ">=0.35.0" },
]
-provides-extras = ["dev"]
+provides-extras = ["bigquery", "postgres", "influxdb", "grafana", "slack",
"all"]
[package.metadata.requires-dev]
-dev = [{ name = "pytest-cov", specifier = ">=7.1.0" }]
+dev = [
+ { name = "autoflake", specifier = ">=2.3.1" },
+ { name = "flake8", specifier = ">=7.3.0" },
+ { name = "hypothesis", specifier = ">=6.0" },
+ { name = "isort", specifier = ">=7.0.0" },
+ { name = "pre-commit", specifier = "==4.5.0" },
+ { name = "pytest", specifier = ">=9.0.1" },
+ { name = "pytest-benchmark", specifier = ">=5.2.3" },
+ { name = "pytest-cov", specifier = ">=7.1.0" },
+ { name = "ruff", specifier = ">=0.14.8" },
+ { name = "tox", specifier = "==4.32.0" },
+]
[[package]]
name = "asn1crypto"