diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index f23a6e1e..9843ecdc 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 1f674604..6a506195 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 7f68e94d..323b40fd 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 b6d7afdf..352fdc52 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 -> example configuration and [local_samples.csv](../examples/csv/data/local_samples.csv) +> 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 @@ -223,4 +222,4 @@ The `--branch` option can also be set via the `BRANCH` environment variable: ``` BRANCH=feature-xyz otava analyze my-product.test -``` \ No newline at end of file +``` diff --git a/docs/BIG_QUERY.md b/docs/BIG_QUERY.md index 5c0ade58..4b51ccf6 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 eef80132..76a91cdb 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 5a5b1453..20c4a330 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 4d5fb0d4..824eeb4f 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 2f011293..abc13027 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 024131f1..fd8c31ab 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 1844f89b..c4c13622 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 870aa5d2..ba6ced06 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 00000000..0416db08 --- /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 00000000..3fdafd90 --- /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 8ceb5a5d..47088639 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 @@ def __init__(self, config: BigQueryConfig): 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 @@ def client(self) -> bigquery.Client: 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 b853f3a4..ea0e7fa9 100644 --- a/otava/grafana.py +++ b/otava/grafana.py @@ -19,9 +19,13 @@ 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 @@ def fetch_annotations( 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 @@ def fetch_annotations( 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 @@ def delete_annotations(self, *ids: int): 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 @@ def create_annotations(self, *annotations: Annotation): 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 @@ def create_annotations(self, *annotations: Annotation): 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 05524059..a0e31a49 100644 --- a/otava/importer.py +++ b/otava/importer.py @@ -24,9 +24,8 @@ 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 @@ def fetch_data( ) # 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 @@ def fetch_data(self, test_conf: TestConfig, selector: DataSelector = DataSelecto 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 af5a53b8..c4aa450d 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 @@ def __init__(self, config: InfluxDBConfig): @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 3bb164de..395061f6 100644 --- a/otava/main.py +++ b/otava/main.py @@ -22,9 +22,9 @@ 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.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 @@ def __init__(self, conf: Config): 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 @@ def update_bigquery(self, test: BigQueryTestConfig, series: AnalyzedSeries): 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 @@ def notify_slack( 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 cbf22af6..d00631d2 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 441f0836..22ee8b5e 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 @@ def notify( 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 53e3762f..629a247d 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 00000000..72b300d3 --- /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 25c90a33..14fcdde4 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 @@ 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 00000000..f338dffe --- /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 9b08a4e2..dd89904c 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"