This is an automated email from the ASF dual-hosted git repository.

seanmccarthy 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 69d2b97  OTAVA-82: use ConfigArgParse to create Config (#86)
69d2b97 is described below

commit 69d2b97a6c38873b74755bf1104a69f099b922b9
Author: Sean McCarthy <[email protected]>
AuthorDate: Wed Aug 20 08:30:26 2025 -0400

    OTAVA-82: use ConfigArgParse to create Config (#86)
---
 otava/bigquery.py                                  |  14 ++
 otava/config.py                                    | 186 ++++++++++-----------
 otava/grafana.py                                   |  14 ++
 otava/graphite.py                                  |  10 ++
 otava/main.py                                      |  38 +++--
 otava/postgres.py                                  |  18 ++
 otava/slack.py                                     |  14 ++
 pyproject.toml                                     |   2 +-
 tests/config_test.py                               |  71 +++++++-
 .../resources/substitution_test_config.yaml        |  43 ++---
 uv.lock                                            |  26 +--
 11 files changed, 272 insertions(+), 164 deletions(-)

diff --git a/otava/bigquery.py b/otava/bigquery.py
index 7ef7c92..c9b77a6 100644
--- a/otava/bigquery.py
+++ b/otava/bigquery.py
@@ -32,6 +32,20 @@ class BigQueryConfig:
     dataset: str
     credentials: str
 
+    @staticmethod
+    def add_parser_args(arg_group):
+        arg_group.add_argument("--bigquery-project-id", help="BigQuery project 
ID", env_var="BIGQUERY_PROJECT_ID")
+        arg_group.add_argument("--bigquery-dataset", help="BigQuery dataset", 
env_var="BIGQUERY_DATASET")
+        arg_group.add_argument("--bigquery-credentials", help="BigQuery 
credentials file", env_var="BIGQUERY_VAULT_SECRET")
+
+    @staticmethod
+    def from_parser_args(args):
+        return BigQueryConfig(
+            project_id=getattr(args, 'bigquery_project_id', None),
+            dataset=getattr(args, 'bigquery_dataset', None),
+            credentials=getattr(args, 'bigquery_credentials', None)
+        )
+
 
 @dataclass
 class BigQueryError(Exception):
diff --git a/otava/config.py b/otava/config.py
index 37936c6..c0e8d54 100644
--- a/otava/config.py
+++ b/otava/config.py
@@ -14,13 +14,12 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-import os
+import logging
 from dataclasses import dataclass
 from pathlib import Path
 from typing import Dict, List, Optional
 
-from expandvars import expandvars
+import configargparse
 from ruamel.yaml import YAML
 
 from otava.bigquery import BigQueryConfig
@@ -96,107 +95,90 @@ def load_test_groups(config: Dict, tests: Dict[str, 
TestConfig]) -> Dict[str, Li
     return result
 
 
-def load_config_from(config_file: Path) -> Config:
-    """Loads config from the specified location"""
-    try:
-        content = expandvars(config_file.read_text(), nounset=True)
+def load_config_from_parser_args(args: configargparse.Namespace) -> Config:
+    config_file = getattr(args, "config_file", None)
+    if config_file is not None:
         yaml = YAML(typ="safe")
-        config = yaml.load(content)
-        """
-        if Grafana configs not explicitly set in yaml file, default to same as 
Graphite
-        server at port 3000
-        """
-        graphite_config = None
-        grafana_config = None
-        if "graphite" in config:
-            if "url" not in config["graphite"]:
-                raise ValueError("graphite.url")
-            graphite_config = GraphiteConfig(url=config["graphite"]["url"])
-            if config.get("grafana") is None:
-                config["grafana"] = {}
-                config["grafana"]["url"] = 
f"{config['graphite']['url'].strip('/')}:3000/"
-                config["grafana"]["user"] = os.environ.get("GRAFANA_USER", 
"admin")
-                config["grafana"]["password"] = 
os.environ.get("GRAFANA_PASSWORD", "admin")
-            grafana_config = GrafanaConfig(
-                url=config["grafana"]["url"],
-                user=config["grafana"]["user"],
-                password=config["grafana"]["password"],
-            )
-
-        slack_config = None
-        if config.get("slack") is not None:
-            if not config["slack"]["token"]:
-                raise ValueError("slack.token")
-            slack_config = SlackConfig(
-                bot_token=config["slack"]["token"],
-            )
-
-        postgres_config = None
-        if config.get("postgres") is not None:
-            if not config["postgres"]["hostname"]:
-                raise ValueError("postgres.hostname")
-            if not config["postgres"]["port"]:
-                raise ValueError("postgres.port")
-            if not config["postgres"]["username"]:
-                raise ValueError("postgres.username")
-            if not config["postgres"]["password"]:
-                raise ValueError("postgres.password")
-            if not config["postgres"]["database"]:
-                raise ValueError("postgres.database")
-
-            postgres_config = PostgresConfig(
-                hostname=config["postgres"]["hostname"],
-                port=config["postgres"]["port"],
-                username=config["postgres"]["username"],
-                password=config["postgres"]["password"],
-                database=config["postgres"]["database"],
-            )
-
-        bigquery_config = None
-        if config.get("bigquery") is not None:
-            bigquery_config = BigQueryConfig(
-                project_id=config["bigquery"]["project_id"],
-                dataset=config["bigquery"]["dataset"],
-                credentials=config["bigquery"]["credentials"],
-            )
+        config = yaml.load(Path(config_file).read_text())
 
         templates = load_templates(config)
         tests = load_tests(config, templates)
         groups = load_test_groups(config, tests)
-
-        return Config(
-            graphite=graphite_config,
-            grafana=grafana_config,
-            slack=slack_config,
-            postgres=postgres_config,
-            bigquery=bigquery_config,
-            tests=tests,
-            test_groups=groups,
-        )
-
-    except FileNotFoundError as e:
-        raise ConfigError(f"Configuration file not found: {e.filename}")
-    except KeyError as e:
-        raise ConfigError(f"Configuration key not found: {e.args[0]}")
-    except ValueError as e:
-        raise ConfigError(f"Value for configuration key not found: 
{e.args[0]}")
-
-
-def load_config() -> Config:
-    """Loads config from one of the default locations"""
-
-    env_config_path = os.environ.get("OTAVA_CONFIG")
-    if env_config_path:
-        return load_config_from(Path(env_config_path).absolute())
-
-    paths = [
-        Path().home() / ".otava/otava.yaml",
-        Path().home() / ".otava/conf.yaml",
-        Path(os.path.realpath(__file__)).parent / "resources/otava.yaml",
-    ]
-
-    for p in paths:
-        if p.exists():
-            return load_config_from(p)
-
-    raise ConfigError(f"No configuration file found. Checked $OTAVA_CONFIG and 
searched: {paths}")
+    else:
+        logging.warning("Otava configuration file not found or not specified")
+        tests = {}
+        groups = {}
+
+    return Config(
+        graphite=GraphiteConfig.from_parser_args(args),
+        grafana=GrafanaConfig.from_parser_args(args),
+        slack=SlackConfig.from_parser_args(args),
+        postgres=PostgresConfig.from_parser_args(args),
+        bigquery=BigQueryConfig.from_parser_args(args),
+        tests=tests,
+        test_groups=groups,
+    )
+
+
+class NestedYAMLConfigFileParser(configargparse.ConfigFileParser):
+    """
+    Custom YAML config file parser that supports nested YAML structures.
+    Maps nested keys like 'slack: {token: value}' to 'slack-token=value', i.e. 
CLI argument style.
+    Recasts values from YAML inferred types to strings as expected for CLI 
arguments.
+    """
+
+    def parse(self, stream):
+        yaml = YAML(typ="safe")
+        config_data = yaml.load(stream)
+        if config_data is None:
+            return {}
+        flattened_dict = {}
+        self._flatten_dict(config_data, flattened_dict)
+        return flattened_dict
+
+    def _flatten_dict(self, nested_dict, flattened_dict, prefix=''):
+        """Recursively flatten nested dictionaries using CLI dash-separated 
notation for keys."""
+        if not isinstance(nested_dict, dict):
+            return
+
+        for key, value in nested_dict.items():
+            new_key = f"{prefix}{key}" if prefix else key
+
+            # yaml keys typically use snake case
+            # replace underscore with dash to convert snake case to CLI 
dash-separated style
+            new_key = new_key.replace("_", "-")
+
+            if isinstance(value, dict):
+                # Recursively process nested dictionaries
+                self._flatten_dict(value, flattened_dict, f"{new_key}-")
+            else:
+                # Add leaf values to the flattened dictionary
+                # Value must be cast to string here, so arg parser can cast 
from string to expected type later
+                flattened_dict[new_key] = str(value)
+
+
+def create_config_parser() -> configargparse.ArgumentParser:
+    parser = configargparse.ArgumentParser(
+        add_help=False,
+        config_file_parser_class=NestedYAMLConfigFileParser,
+        default_config_files=[
+            Path().home() / ".otava/conf.yaml",
+            Path().home() / ".otava/otava.yaml",
+        ],
+        allow_abbrev=False,  # required for correct parsing of nested values 
from config file
+    )
+    parser.add_argument('--config-file', is_config_file=True, help='Otava 
config file path', env_var="OTAVA_CONFIG")
+    GraphiteConfig.add_parser_args(parser.add_argument_group('Graphite 
Options', 'Options for Graphite configuration'))
+    GrafanaConfig.add_parser_args(parser.add_argument_group('Grafana Options', 
'Options for Grafana configuration'))
+    SlackConfig.add_parser_args(parser.add_argument_group('Slack Options', 
'Options for Slack configuration'))
+    PostgresConfig.add_parser_args(parser.add_argument_group('Postgres 
Options', 'Options for Postgres configuration'))
+    BigQueryConfig.add_parser_args(parser.add_argument_group('BigQuery 
Options', 'Options for BigQuery configuration'))
+    return parser
+
+
+def load_config_from_file(config_file: str, arg_overrides: Optional[List[str]] 
= None) -> Config:
+    if arg_overrides is None:
+        arg_overrides = []
+    arg_overrides.extend(["--config-file", config_file])
+    args, _ = create_config_parser().parse_known_args(args=arg_overrides)
+    return load_config_from_parser_args(args)
diff --git a/otava/grafana.py b/otava/grafana.py
index dbf9e42..bc90998 100644
--- a/otava/grafana.py
+++ b/otava/grafana.py
@@ -30,6 +30,20 @@ class GrafanaConfig:
     user: str
     password: str
 
+    @staticmethod
+    def add_parser_args(arg_group):
+        arg_group.add_argument("--grafana-url", help="Grafana server URL", 
env_var="GRAFANA_ADDRESS")
+        arg_group.add_argument("--grafana-user", help="Grafana server user", 
env_var="GRAFANA_USER", default="admin")
+        arg_group.add_argument("--grafana-password", help="Grafana server 
password", env_var="GRAFANA_PASSWORD", default="admin")
+
+    @staticmethod
+    def from_parser_args(args):
+        return GrafanaConfig(
+            url=getattr(args, 'grafana_url', None),
+            user=getattr(args, 'grafana_user', None),
+            password=getattr(args, 'grafana_password', None)
+        )
+
 
 @dataclass
 class GrafanaError(Exception):
diff --git a/otava/graphite.py b/otava/graphite.py
index 6536079..7c3709d 100644
--- a/otava/graphite.py
+++ b/otava/graphite.py
@@ -31,6 +31,16 @@ from otava.util import parse_datetime
 class GraphiteConfig:
     url: str
 
+    @staticmethod
+    def add_parser_args(arg_group):
+        arg_group.add_argument("--graphite-url", help="Graphite server URL", 
env_var="GRAPHITE_ADDRESS")
+
+    @staticmethod
+    def from_parser_args(args):
+        return GraphiteConfig(
+            url=getattr(args, 'graphite_url', None)
+        )
+
 
 @dataclass
 class DataPoint:
diff --git a/otava/main.py b/otava/main.py
index a158f2c..60d6e37 100644
--- a/otava/main.py
+++ b/otava/main.py
@@ -15,7 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import argparse
 import copy
 import logging
 import sys
@@ -23,13 +22,14 @@ from dataclasses import dataclass
 from datetime import datetime, timedelta
 from typing import Dict, List, Optional
 
+import configargparse as argparse
 import pytz
 from slack_sdk import WebClient
 
 from otava import config
 from otava.attributes import get_back_links
 from otava.bigquery import BigQuery, BigQueryError
-from otava.config import Config, ConfigError
+from otava.config import Config
 from otava.data_selector import DataSelector
 from otava.grafana import Annotation, Grafana, GrafanaError
 from otava.graphite import GraphiteError
@@ -514,19 +514,12 @@ def analysis_options_from_args(args: argparse.Namespace) 
-> AnalysisOptions:
     return conf
 
 
-def main():
-    try:
-        conf = config.load_config()
-    except ConfigError as err:
-        logging.error(err.message)
-        exit(1)
-    script_main(conf)
-
-
-def script_main(conf: Config, args: List[str] = None):
-    logging.basicConfig(format="%(levelname)s: %(message)s", 
level=logging.INFO)
-
-    parser = argparse.ArgumentParser(description="Hunts performance 
regressions in Fallout results")
+def create_otava_cli_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="Hunts performance regressions in Fallout results",
+        parents=[config.create_config_parser()],
+        allow_abbrev=False,  # required for correct parsing of nested values 
from config file
+    )
 
     subparsers = parser.add_subparsers(dest="command")
     list_tests_parser = subparsers.add_parser("list-tests", help="list 
available tests")
@@ -601,8 +594,17 @@ def script_main(conf: Config, args: List[str] = None):
         "validate", help="validates the tests and metrics defined in the 
configuration"
     )
 
+    return parser
+
+
+def script_main(conf: Config = None, args: List[str] = None):
+    logging.basicConfig(format="%(levelname)s: %(message)s", 
level=logging.INFO)
+    parser = create_otava_cli_parser()
+
     try:
-        args = parser.parse_args(args=args)
+        args, _ = parser.parse_known_args(args=args)
+        if conf is None:
+            conf = config.load_config_from_parser_args(args)
         otava = Otava(conf)
 
         if args.command == "list-groups":
@@ -727,5 +729,9 @@ def script_main(conf: Config, args: List[str] = None):
         exit(1)
 
 
+def main():
+    script_main()
+
+
 if __name__ == "__main__":
     main()
diff --git a/otava/postgres.py b/otava/postgres.py
index 6b98c0f..d014bb6 100644
--- a/otava/postgres.py
+++ b/otava/postgres.py
@@ -33,6 +33,24 @@ class PostgresConfig:
     password: str
     database: str
 
+    @staticmethod
+    def add_parser_args(arg_group):
+        arg_group.add_argument("--postgres-hostname", help="PostgreSQL server 
hostname", env_var="POSTGRES_HOSTNAME")
+        arg_group.add_argument("--postgres-port", type=int, help="PostgreSQL 
server port", env_var="POSTGRES_PORT")
+        arg_group.add_argument("--postgres-username", help="PostgreSQL 
username", env_var="POSTGRES_USERNAME")
+        arg_group.add_argument("--postgres-password", help="PostgreSQL 
password", env_var="POSTGRES_PASSWORD")
+        arg_group.add_argument("--postgres-database", help="PostgreSQL 
database name", env_var="POSTGRES_DATABASE")
+
+    @staticmethod
+    def from_parser_args(args):
+        return PostgresConfig(
+            hostname=getattr(args, 'postgres_hostname', None),
+            port=getattr(args, 'postgres_port', None),
+            username=getattr(args, 'postgres_username', None),
+            password=getattr(args, 'postgres_password', None),
+            database=getattr(args, 'postgres_database', None)
+        )
+
 
 @dataclass
 class PostgresError(Exception):
diff --git a/otava/slack.py b/otava/slack.py
index 1cad43f..0d9b751 100644
--- a/otava/slack.py
+++ b/otava/slack.py
@@ -36,6 +36,20 @@ class NotificationError(Exception):
 class SlackConfig:
     bot_token: str
 
+    @staticmethod
+    def add_parser_args(parser):
+        parser.add_argument(
+            "--slack-token",
+            help="Slack bot token to use for sending notifications",
+            env_var="SLACK_BOT_TOKEN",
+        )
+
+    @staticmethod
+    def from_parser_args(args):
+        return SlackConfig(
+            bot_token=getattr(args, "slack_token", None)
+        )
+
 
 class SlackNotification:
     tests_with_insufficient_data: List[str]
diff --git a/pyproject.toml b/pyproject.toml
index 6dd3f17..8064b30 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,6 @@ classifiers = [
 ]
 dependencies = [
     "dateparser>=1.0.0",
-    "expandvars>=0.6.5",
     "numpy==1.24.*",
     "python-dateutil>=2.8.1",
     "signal-processing-algorithms==1.3.5",
@@ -48,6 +47,7 @@ dependencies = [
     "slack-sdk>=3.4.2",
     "google-cloud-bigquery>=3.25.0",
     "pg8000>=1.31.2",
+    "configargparse>=1.7.1",
 ]
 
 [project.optional-dependencies]
diff --git a/tests/config_test.py b/tests/config_test.py
index a59e83c..f1e4a1b 100644
--- a/tests/config_test.py
+++ b/tests/config_test.py
@@ -14,15 +14,16 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import os
 
-from pathlib import Path
+import pytest
 
-from otava.config import load_config_from
+from otava.config import load_config_from_file
 from otava.test_config import CsvTestConfig, GraphiteTestConfig, 
HistoStatTestConfig
 
 
 def test_load_graphite_tests():
-    config = load_config_from(Path("tests/resources/sample_config.yaml"))
+    config = load_config_from_file("tests/resources/sample_config.yaml")
     tests = config.tests
     assert len(tests) == 4
     test = tests["remote1"]
@@ -39,7 +40,7 @@ def test_load_graphite_tests():
 
 
 def test_load_csv_tests():
-    config = load_config_from(Path("tests/resources/sample_config.yaml"))
+    config = load_config_from_file("tests/resources/sample_config.yaml")
     tests = config.tests
     assert len(tests) == 4
     test = tests["local1"]
@@ -60,17 +61,75 @@ def test_load_csv_tests():
 
 
 def test_load_test_groups():
-    config = load_config_from(Path("tests/resources/sample_config.yaml"))
+    config = load_config_from_file("tests/resources/sample_config.yaml")
     groups = config.test_groups
     assert len(groups) == 2
     assert len(groups["remote"]) == 2
 
 
 def test_load_histostat_config():
-    config = 
load_config_from(Path("tests/resources/histostat_test_config.yaml"))
+    config = 
load_config_from_file("tests/resources/histostat_test_config.yaml")
     tests = config.tests
     assert len(tests) == 1
     test = tests["histostat-sample"]
     assert isinstance(test, HistoStatTestConfig)
     # 14 tags * 12 tag_metrics == 168 unique metrics
     assert len(test.fully_qualified_metric_names()) == 168
+
+
[email protected](
+    "config_property",
+    [
+        # property, accessor, env_var, cli_flag, [config value, env value, cli 
value]
+        ("slack_token", lambda c: c.slack.bot_token, "SLACK_BOT_TOKEN", 
"--slack-token"),
+        ("bigquery_project_id", lambda c: c.bigquery.project_id, 
"BIGQUERY_PROJECT_ID", "--bigquery-project-id"),
+        ("bigquery_dataset", lambda c: c.bigquery.dataset, "BIGQUERY_DATASET", 
"--bigquery-dataset"),
+        ("bigquery_credentials", lambda c: c.bigquery.credentials, 
"BIGQUERY_VAULT_SECRET", "--bigquery-credentials"),
+        ("grafana_url", lambda c: c.grafana.url, "GRAFANA_ADDRESS", 
"--grafana-url"),
+        ("grafana_user", lambda c: c.grafana.user, "GRAFANA_USER", 
"--grafana-user"),
+        ("grafana_password", lambda c: c.grafana.password, "GRAFANA_PASSWORD", 
"--grafana-password"),
+        ("graphite_url", lambda c: c.graphite.url, "GRAPHITE_ADDRESS", 
"--graphite-url"),
+        ("postgres_hostname", lambda c: c.postgres.hostname, 
"POSTGRES_HOSTNAME", "--postgres-hostname"),
+        ("postgres_port", lambda c: c.postgres.port, "POSTGRES_PORT", 
"--postgres-port", 1111, 2222, 3333),
+        ("postgres_username", lambda c: c.postgres.username, 
"POSTGRES_USERNAME", "--postgres-username"),
+        ("postgres_password", lambda c: c.postgres.password, 
"POSTGRES_PASSWORD", "--postgres-password"),
+        ("postgres_database", lambda c: c.postgres.database, 
"POSTGRES_DATABASE", "--postgres-database"),
+    ],
+    ids=lambda v: v[0],  # use the property name for the parameterized test 
name
+)
+def test_configuration_substitutions(config_property):
+    config_file = "tests/resources/substitution_test_config.yaml"
+    accessor = config_property[1]
+
+    if len(config_property) == 4:
+        config_value = f"config_{config_property[0]}"
+        env_config_value = f"env_{config_property[0]}"
+        cli_config_value = f"cli_{config_property[0]}"
+    else:
+        config_value = config_property[4]
+        env_config_value = config_property[5]
+        cli_config_value = config_property[6]
+
+    # test value from config file
+    config = load_config_from_file(config_file)
+    assert accessor(config) == config_value
+
+    # test env var overrides values from config file
+    os.environ[config_property[2]] = str(env_config_value)
+    try:
+        config = load_config_from_file(config_file)
+        assert accessor(config) == env_config_value
+    finally:
+        os.environ.pop(config_property[2])
+
+    # test cli values override values from config file
+    config = load_config_from_file(config_file, 
arg_overrides=[config_property[3], str(cli_config_value)])
+    assert accessor(config) == cli_config_value
+
+    # test cli values override values from config file and env var
+    os.environ[config_property[2]] = str(env_config_value)
+    try:
+        config = load_config_from_file(config_file, 
arg_overrides=[config_property[3], str(cli_config_value)])
+        assert accessor(config) == cli_config_value
+    finally:
+        os.environ.pop(config_property[2])
diff --git a/otava/resources/otava.yaml 
b/tests/resources/substitution_test_config.yaml
similarity index 60%
rename from otava/resources/otava.yaml
rename to tests/resources/substitution_test_config.yaml
index 44479f3..6b6b024 100644
--- a/otava/resources/otava.yaml
+++ b/tests/resources/substitution_test_config.yaml
@@ -15,34 +15,25 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# External systems connectors configuration:
-graphite:
-  url: ${GRAPHITE_ADDRESS}
-
-grafana:
-  url: ${GRAFANA_ADDRESS}
-  user: ${GRAFANA_USER}
-  password: ${GRAFANA_PASSWORD}
-
 slack:
-  token: ${SLACK_BOT_TOKEN}
+  token: config_slack_token
 
-# Templates define common bits shared between test definitions:
-templates:
+graphite:
+  url: config_graphite_url
 
-# Define your tests here:
-tests:
-  local.sample:
-    type: csv
-    file: tests/resources/sample.csv
-    time_column: time
-    metrics: [metric1, metric2]
-    attributes: [commit]
-    csv_options:
-      delimiter: ','
-      quote_char: "'"
+grafana:
+  url: config_grafana_url
+  user: config_grafana_user
+  password: config_grafana_password
 
+bigquery:
+  project_id: config_bigquery_project_id
+  dataset: config_bigquery_dataset
+  credentials: config_bigquery_credentials
 
-test_groups:
-  local:
-    - local.sample
+postgres:
+  hostname: config_postgres_hostname
+  port: 1111
+  username: config_postgres_username
+  password: config_postgres_password
+  database: config_postgres_database
diff --git a/uv.lock b/uv.lock
index fb4d970..a5beddf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
 version = 1
-revision = 2
+revision = 3
 requires-python = ">=3.8, <3.11"
 resolution-markers = [
     "python_full_version >= '3.10'",
@@ -13,8 +13,8 @@ name = "apache-otava"
 version = "0.6.1"
 source = { editable = "." }
 dependencies = [
+    { name = "configargparse" },
     { name = "dateparser" },
-    { name = "expandvars" },
     { name = "google-cloud-bigquery", version = "3.30.0", source = { registry 
= "https://pypi.org/simple"; }, marker = "python_full_version < '3.9'" },
     { name = "google-cloud-bigquery", version = "3.35.1", source = { registry 
= "https://pypi.org/simple"; }, marker = "python_full_version >= '3.9'" },
     { name = "numpy" },
@@ -53,8 +53,8 @@ dev = [
 [package.metadata]
 requires-dist = [
     { name = "autoflake", marker = "extra == 'dev'", specifier = ">=1.4" },
+    { name = "configargparse", specifier = ">=1.7.1" },
     { name = "dateparser", specifier = ">=1.0.0" },
-    { name = "expandvars", specifier = ">=0.6.5" },
     { name = "flake8", marker = "extra == 'dev'", specifier = ">=4.0.1" },
     { name = "google-cloud-bigquery", specifier = ">=3.25.0" },
     { name = "isort", marker = "extra == 'dev'", specifier = ">=5.10.1" },
@@ -218,6 +218,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl";,
 hash = 
"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size 
= 25335, upload-time = "2022-10-25T02:36:20.889Z" },
 ]
 
+[[package]]
+name = "configargparse"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/85/4d/6c9ef746dfcc2a32e26f3860bb4a011c008c392b83eabdfb598d1a8bbe5d/configargparse-1.7.1.tar.gz";,
 hash = 
"sha256:79c2ddae836a1e5914b71d58e4b9adbd9f7779d4e6351a637b7d2d9b6c46d3d9", size 
= 43958, upload-time = "2025-05-23T14:26:17.369Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/31/28/d28211d29bcc3620b1fece85a65ce5bb22f18670a03cd28ea4b75ede270c/configargparse-1.7.1-py3-none-any.whl";,
 hash = 
"sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6", size 
= 25607, upload-time = "2025-05-23T14:26:15.923Z" },
+]
+
 [[package]]
 name = "dateparser"
 version = "1.2.0"
@@ -252,15 +261,6 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl";,
 hash = 
"sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size 
= 16453, upload-time = "2024-07-12T22:25:58.476Z" },
 ]
 
-[[package]]
-name = "expandvars"
-version = "1.1.1"
-source = { registry = "https://pypi.org/simple"; }
-sdist = { url = 
"https://files.pythonhosted.org/packages/dc/c9/c0a46f462058446aafe953bf76a957c17f78550216a95fbded2270f83117/expandvars-1.1.1.tar.gz";,
 hash = 
"sha256:98add8268b760dfee457bde1c17bf745795fdebc22b7ddab75fd3278653f1e05", size 
= 70787, upload-time = "2025-07-12T07:46:22.308Z" }
-wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/2b/ca/0753ba3a81255ac49748ec8b665ab01f8efcf711f74bbccb5457a6193acc/expandvars-1.1.1-py3-none-any.whl";,
 hash = 
"sha256:09ca39e6bfcb0d899db8778a00dd3d89cfeb0080795c54f16f6279afd0ef8c5b", size 
= 7522, upload-time = "2025-07-12T07:46:18.984Z" },
-]
-
 [[package]]
 name = "filelock"
 version = "3.16.1"
@@ -707,7 +707,7 @@ resolution-markers = [
     "python_full_version == '3.9.*'",
 ]
 dependencies = [
-    { name = "zipp", version = "3.23.0", source = { registry = 
"https://pypi.org/simple"; }, marker = "python_full_version >= '3.9'" },
+    { name = "zipp", version = "3.23.0", source = { registry = 
"https://pypi.org/simple"; }, marker = "python_full_version == '3.9.*'" },
 ]
 sdist = { url = 
"https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz";,
 hash = 
"sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size 
= 56641, upload-time = "2025-04-27T15:29:01.736Z" }
 wheels = [

Reply via email to