This is an automated email from the ASF dual-hosted git repository. asorokoumov pushed a commit to branch fixup-0.7.0-rc3 in repository https://gitbox.apache.org/repos/asf/otava.git
commit 444e7e35b5e0f3fc0dc66fc5ec5e1a36dc4f573d Author: Alex Sorokoumov <[email protected]> AuthorDate: Thu Nov 27 20:34:10 2025 -0800 Revert "OTAVA-82: use ConfigArgParse to create Config (#86)" This reverts commit 69d2b97a6c38873b74755bf1104a69f099b922b9. --- otava/bigquery.py | 14 -- otava/config.py | 186 +++++++++++---------- otava/grafana.py | 14 -- otava/graphite.py | 10 -- otava/main.py | 38 ++--- otava/postgres.py | 18 -- .../resources/otava.yaml | 43 +++-- otava/slack.py | 14 -- pyproject.toml | 2 +- tests/config_test.py | 71 +------- uv.lock | 26 +-- 11 files changed, 164 insertions(+), 272 deletions(-) diff --git a/otava/bigquery.py b/otava/bigquery.py index c9b77a6..7ef7c92 100644 --- a/otava/bigquery.py +++ b/otava/bigquery.py @@ -32,20 +32,6 @@ 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 c0e8d54..37936c6 100644 --- a/otava/config.py +++ b/otava/config.py @@ -14,12 +14,13 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -import logging + +import os from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional -import configargparse +from expandvars import expandvars from ruamel.yaml import YAML from otava.bigquery import BigQueryConfig @@ -95,90 +96,107 @@ def load_test_groups(config: Dict, tests: Dict[str, TestConfig]) -> Dict[str, Li return result -def load_config_from_parser_args(args: configargparse.Namespace) -> Config: - config_file = getattr(args, "config_file", None) - if config_file is not None: +def load_config_from(config_file: Path) -> Config: + """Loads config from the specified location""" + try: + content = expandvars(config_file.read_text(), nounset=True) yaml = YAML(typ="safe") - config = yaml.load(Path(config_file).read_text()) + 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"], + ) templates = load_templates(config) tests = load_tests(config, templates) groups = load_test_groups(config, tests) - 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) + + 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}") diff --git a/otava/grafana.py b/otava/grafana.py index bc90998..dbf9e42 100644 --- a/otava/grafana.py +++ b/otava/grafana.py @@ -30,20 +30,6 @@ 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 7c3709d..6536079 100644 --- a/otava/graphite.py +++ b/otava/graphite.py @@ -31,16 +31,6 @@ 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 60d6e37..a158f2c 100644 --- a/otava/main.py +++ b/otava/main.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +import argparse import copy import logging import sys @@ -22,14 +23,13 @@ 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 +from otava.config import Config, ConfigError from otava.data_selector import DataSelector from otava.grafana import Annotation, Grafana, GrafanaError from otava.graphite import GraphiteError @@ -514,12 +514,19 @@ def analysis_options_from_args(args: argparse.Namespace) -> AnalysisOptions: return conf -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 - ) +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") subparsers = parser.add_subparsers(dest="command") list_tests_parser = subparsers.add_parser("list-tests", help="list available tests") @@ -594,17 +601,8 @@ def create_otava_cli_parser() -> argparse.ArgumentParser: "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_known_args(args=args) - if conf is None: - conf = config.load_config_from_parser_args(args) + args = parser.parse_args(args=args) otava = Otava(conf) if args.command == "list-groups": @@ -729,9 +727,5 @@ def script_main(conf: Config = None, 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 d014bb6..6b98c0f 100644 --- a/otava/postgres.py +++ b/otava/postgres.py @@ -33,24 +33,6 @@ 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/tests/resources/substitution_test_config.yaml b/otava/resources/otava.yaml similarity index 60% rename from tests/resources/substitution_test_config.yaml rename to otava/resources/otava.yaml index 6b6b024..44479f3 100644 --- a/tests/resources/substitution_test_config.yaml +++ b/otava/resources/otava.yaml @@ -15,25 +15,34 @@ # specific language governing permissions and limitations # under the License. -slack: - token: config_slack_token - +# External systems connectors configuration: graphite: - url: config_graphite_url + url: ${GRAPHITE_ADDRESS} grafana: - url: config_grafana_url - user: config_grafana_user - password: config_grafana_password + url: ${GRAFANA_ADDRESS} + user: ${GRAFANA_USER} + password: ${GRAFANA_PASSWORD} + +slack: + token: ${SLACK_BOT_TOKEN} + +# Templates define common bits shared between test definitions: +templates: + +# 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: "'" -bigquery: - project_id: config_bigquery_project_id - dataset: config_bigquery_dataset - credentials: config_bigquery_credentials -postgres: - hostname: config_postgres_hostname - port: 1111 - username: config_postgres_username - password: config_postgres_password - database: config_postgres_database +test_groups: + local: + - local.sample diff --git a/otava/slack.py b/otava/slack.py index 0d9b751..1cad43f 100644 --- a/otava/slack.py +++ b/otava/slack.py @@ -36,20 +36,6 @@ 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 24e3b36..46edadd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ ] dependencies = [ "dateparser>=1.0.0", + "expandvars>=0.6.5", "numpy==1.24.*", "python-dateutil>=2.8.1", "signal-processing-algorithms==1.3.5", @@ -47,7 +48,6 @@ 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 f1e4a1b..a59e83c 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -14,16 +14,15 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -import os -import pytest +from pathlib import Path -from otava.config import load_config_from_file +from otava.config import load_config_from from otava.test_config import CsvTestConfig, GraphiteTestConfig, HistoStatTestConfig def test_load_graphite_tests(): - config = load_config_from_file("tests/resources/sample_config.yaml") + config = load_config_from(Path("tests/resources/sample_config.yaml")) tests = config.tests assert len(tests) == 4 test = tests["remote1"] @@ -40,7 +39,7 @@ def test_load_graphite_tests(): def test_load_csv_tests(): - config = load_config_from_file("tests/resources/sample_config.yaml") + config = load_config_from(Path("tests/resources/sample_config.yaml")) tests = config.tests assert len(tests) == 4 test = tests["local1"] @@ -61,75 +60,17 @@ def test_load_csv_tests(): def test_load_test_groups(): - config = load_config_from_file("tests/resources/sample_config.yaml") + config = load_config_from(Path("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_file("tests/resources/histostat_test_config.yaml") + config = load_config_from(Path("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/uv.lock b/uv.lock index a5beddf..fb4d970 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 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,15 +218,6 @@ 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" @@ -261,6 +252,15 @@ 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 = [
