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 = [