From aa547f19cf86038d648c4ee8eeff4521ea896c69 Mon Sep 17 00:00:00 2001 From: AnishMudaraddi Date: Mon, 17 Aug 2026 17:53:30 +0100 Subject: [PATCH 01/11] add gitignore --- OpenStack-accounting/.gitignore | 220 ++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 OpenStack-accounting/.gitignore diff --git a/OpenStack-accounting/.gitignore b/OpenStack-accounting/.gitignore new file mode 100644 index 00000000..ad28918c --- /dev/null +++ b/OpenStack-accounting/.gitignore @@ -0,0 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file From 67352eab1f87de9b4abc92a8a52112e49c859574 Mon Sep 17 00:00:00 2001 From: anish-mudaraddi Date: Mon, 17 Aug 2026 15:47:07 +0100 Subject: [PATCH 02/11] WIP accounting scripts rewrite --- OpenStack-accounting/Dockerfile | 35 + OpenStack-accounting/{readme.md => README.md} | 5 + OpenStack-accounting/_old/__init__.py | 0 .../{usr/local/sbin => _old}/accountinglib.py | 12 +- .../cinder_extract_accounting.py | 1 - .../glance_extract_accounting.py | 1 - .../manila_extract_accounting.py | 1 - .../sbin => _old}/nova_extract_accounting.py | 1 - .../etc/thecount/thecount.conf.example | 2 - OpenStack-accounting/pyproject.toml | 57 ++ OpenStack-accounting/requirements.txt | 3 - OpenStack-accounting/tests/conftest.py | 60 ++ OpenStack-accounting/tests/test_cli.py | 211 +++++ .../tests/test_data/cinder/exp.txt | 11 + .../tests/test_data/cinder/input.txt | 11 + .../tests/test_data/glance/exp.txt | 17 + .../tests/test_data/glance/input.txt | 17 + .../tests/test_data/manila/exp.txt | 7 + .../tests/test_data/manila/input.txt | 7 + .../tests/test_data/nova/exp.txt | 11 + .../tests/test_data/nova/input.txt | 11 + OpenStack-accounting/tests/test_jobs.py | 41 + OpenStack-accounting/tests/test_main.py | 177 ++++ OpenStack-accounting/tests/test_sink.py | 94 +++ OpenStack-accounting/tests/test_source.py | 79 ++ OpenStack-accounting/thecount.conf.example | 10 + OpenStack-accounting/thecount/__init__.py | 32 + OpenStack-accounting/thecount/__main__.py | 64 ++ OpenStack-accounting/thecount/cli.py | 160 ++++ OpenStack-accounting/thecount/jobs.py | 228 +++++ OpenStack-accounting/thecount/sink.py | 67 ++ OpenStack-accounting/thecount/source.py | 101 +++ OpenStack-accounting/thecount/structs.py | 25 + .../usr/local/sbin/now-accounting.sh | 17 - .../usr/local/sbin/past-accounting.sh | 29 - OpenStack-accounting/uv.lock | 791 ++++++++++++++++++ 36 files changed, 2336 insertions(+), 60 deletions(-) create mode 100644 OpenStack-accounting/Dockerfile rename OpenStack-accounting/{readme.md => README.md} (84%) create mode 100644 OpenStack-accounting/_old/__init__.py rename OpenStack-accounting/{usr/local/sbin => _old}/accountinglib.py (93%) rename OpenStack-accounting/{usr/local/sbin => _old}/cinder_extract_accounting.py (99%) rename OpenStack-accounting/{usr/local/sbin => _old}/glance_extract_accounting.py (99%) rename OpenStack-accounting/{usr/local/sbin => _old}/manila_extract_accounting.py (99%) rename OpenStack-accounting/{usr/local/sbin => _old}/nova_extract_accounting.py (99%) delete mode 100644 OpenStack-accounting/etc/thecount/thecount.conf.example create mode 100644 OpenStack-accounting/pyproject.toml delete mode 100644 OpenStack-accounting/requirements.txt create mode 100644 OpenStack-accounting/tests/conftest.py create mode 100644 OpenStack-accounting/tests/test_cli.py create mode 100644 OpenStack-accounting/tests/test_data/cinder/exp.txt create mode 100644 OpenStack-accounting/tests/test_data/cinder/input.txt create mode 100644 OpenStack-accounting/tests/test_data/glance/exp.txt create mode 100644 OpenStack-accounting/tests/test_data/glance/input.txt create mode 100644 OpenStack-accounting/tests/test_data/manila/exp.txt create mode 100644 OpenStack-accounting/tests/test_data/manila/input.txt create mode 100644 OpenStack-accounting/tests/test_data/nova/exp.txt create mode 100644 OpenStack-accounting/tests/test_data/nova/input.txt create mode 100644 OpenStack-accounting/tests/test_jobs.py create mode 100644 OpenStack-accounting/tests/test_main.py create mode 100644 OpenStack-accounting/tests/test_sink.py create mode 100644 OpenStack-accounting/tests/test_source.py create mode 100644 OpenStack-accounting/thecount.conf.example create mode 100644 OpenStack-accounting/thecount/__init__.py create mode 100644 OpenStack-accounting/thecount/__main__.py create mode 100644 OpenStack-accounting/thecount/cli.py create mode 100644 OpenStack-accounting/thecount/jobs.py create mode 100644 OpenStack-accounting/thecount/sink.py create mode 100644 OpenStack-accounting/thecount/source.py create mode 100644 OpenStack-accounting/thecount/structs.py delete mode 100644 OpenStack-accounting/usr/local/sbin/now-accounting.sh delete mode 100644 OpenStack-accounting/usr/local/sbin/past-accounting.sh create mode 100644 OpenStack-accounting/uv.lock diff --git a/OpenStack-accounting/Dockerfile b/OpenStack-accounting/Dockerfile new file mode 100644 index 00000000..ffe80bd2 --- /dev/null +++ b/OpenStack-accounting/Dockerfile @@ -0,0 +1,35 @@ +# Use Python 3.12 slim image as base +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies if needed +RUN apt-get update && apt-get install -y \ + --no-install-recommends ca-certificates\ + && rm -rf /var/lib/apt/lists/* + +# Parse and report everything in UTC unless the operator overrides it. +ENV TZ=UTC \ + PYTHONUNBUFFERED=1 \ + THE_COUNT_CONFIG_FILE=/etc/thecount/thecount.conf \ + LOG_LEVEL=INFO \ + LOG_FILE=/var/log/thecount/thecount.log + +# Python dependencies for the extractors. +# Copy package files for installation +COPY pyproject.toml README.md ./ +COPY thecount/ ./thecount/ + +# Install only the package with core dependencies (no optional dependencies) +RUN pip install --no-cache-dir . + +# Create a non-root user +RUN useradd --shell /bin/bash monitoring +RUN chown -R monitoring:monitoring /app +USER monitoring + +# Set the entrypoint to your package's main module +ENTRYPOINT ["python", "-m", "thecount"] + +# Default command (can be overridden) +CMD ["--help"] diff --git a/OpenStack-accounting/readme.md b/OpenStack-accounting/README.md similarity index 84% rename from OpenStack-accounting/readme.md rename to OpenStack-accounting/README.md index 50e5931a..3c2575fd 100644 --- a/OpenStack-accounting/readme.md +++ b/OpenStack-accounting/README.md @@ -13,6 +13,11 @@ A database user with read access to the relevant databases for each OpenStack co ## Installation Copy the scripts into `/usr/local/sbin` as shown in this repo +Create a virtual environment - `python3 -m venv /root/envs/thecount/bin/python` +Activate the virtual environment `sudo source /etc/thecount/thecount` +Install the packages ` + + Create a config file with a database connection string in the format shown in `/etc/thecount/thecount.conf.example` in `/etc/thecount/thecount.conf` Create the stored procedures in the `sql` directory appropriate db. diff --git a/OpenStack-accounting/_old/__init__.py b/OpenStack-accounting/_old/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OpenStack-accounting/usr/local/sbin/accountinglib.py b/OpenStack-accounting/_old/accountinglib.py similarity index 93% rename from OpenStack-accounting/usr/local/sbin/accountinglib.py rename to OpenStack-accounting/_old/accountinglib.py index a6e49ae6..b7d21849 100644 --- a/OpenStack-accounting/usr/local/sbin/accountinglib.py +++ b/OpenStack-accounting/_old/accountinglib.py @@ -15,6 +15,8 @@ import configparser +CONFIG_FP = "/etc/thecount/thecount.conf" + def get_logger(component): logging.basicConfig( filename="/var/log/thecount.log", @@ -47,10 +49,10 @@ def project_to_department(result): def send_to_influx(datastring, logger): """Takes a datastring formatted to send to InfluxDBs rest api. Loads necessary config, sends and returns the response""" # Read from config file - influx_parser = configparser.SafeConfigParser() + influx_parser = configparser.ConfigParser() try: - influx_parser.read("/etc/influxdb.conf") - except Exceptions as exp: + influx_parser.read(CONFIG_FP) + except Exception as e: logger.info(f"Unable to read from influx config file - {str(exp)}") sys.exit(1) try: @@ -59,7 +61,7 @@ def send_to_influx(datastring, logger): username = influx_parser.get("auth", "username") password = influx_parser.get("auth", "password") instance = influx_parser.get("cloud", "instance") - except Exceptions as exp: + except Exception as exp: logger.info(f"Unable to parse influx config file - {str(exp)}") sys.exit(1) finaldatastring = datastring.replace( @@ -76,7 +78,7 @@ def get_accounting_data(database, starttime, endtime, logger): thecount_parser = configparser.RawConfigParser(strict=False) try: - thecount_parser.read("/etc/thecount/thecount.conf") + thecount_parser.read(CONFIG_FP) except Exceptions as exp: logger.info(f"Unable to read from thecount config file - {str(exp)}") sys.exit(1) diff --git a/OpenStack-accounting/usr/local/sbin/cinder_extract_accounting.py b/OpenStack-accounting/_old/cinder_extract_accounting.py similarity index 99% rename from OpenStack-accounting/usr/local/sbin/cinder_extract_accounting.py rename to OpenStack-accounting/_old/cinder_extract_accounting.py index c56fbdf5..0f54b6d4 100644 --- a/OpenStack-accounting/usr/local/sbin/cinder_extract_accounting.py +++ b/OpenStack-accounting/_old/cinder_extract_accounting.py @@ -6,7 +6,6 @@ import sys import time import datetime -import logging def main(): diff --git a/OpenStack-accounting/usr/local/sbin/glance_extract_accounting.py b/OpenStack-accounting/_old/glance_extract_accounting.py similarity index 99% rename from OpenStack-accounting/usr/local/sbin/glance_extract_accounting.py rename to OpenStack-accounting/_old/glance_extract_accounting.py index f241b20f..de69bb99 100644 --- a/OpenStack-accounting/usr/local/sbin/glance_extract_accounting.py +++ b/OpenStack-accounting/_old/glance_extract_accounting.py @@ -6,7 +6,6 @@ import sys import time import datetime -import logging def main(): diff --git a/OpenStack-accounting/usr/local/sbin/manila_extract_accounting.py b/OpenStack-accounting/_old/manila_extract_accounting.py similarity index 99% rename from OpenStack-accounting/usr/local/sbin/manila_extract_accounting.py rename to OpenStack-accounting/_old/manila_extract_accounting.py index cf6b8fe6..057d0122 100644 --- a/OpenStack-accounting/usr/local/sbin/manila_extract_accounting.py +++ b/OpenStack-accounting/_old/manila_extract_accounting.py @@ -6,7 +6,6 @@ import sys import time import datetime -import logging def main(): diff --git a/OpenStack-accounting/usr/local/sbin/nova_extract_accounting.py b/OpenStack-accounting/_old/nova_extract_accounting.py similarity index 99% rename from OpenStack-accounting/usr/local/sbin/nova_extract_accounting.py rename to OpenStack-accounting/_old/nova_extract_accounting.py index 1eae67bd..de5d2d7a 100644 --- a/OpenStack-accounting/usr/local/sbin/nova_extract_accounting.py +++ b/OpenStack-accounting/_old/nova_extract_accounting.py @@ -6,7 +6,6 @@ import sys import time import datetime -import logging def main(): diff --git a/OpenStack-accounting/etc/thecount/thecount.conf.example b/OpenStack-accounting/etc/thecount/thecount.conf.example deleted file mode 100644 index a0dd7141..00000000 --- a/OpenStack-accounting/etc/thecount/thecount.conf.example +++ /dev/null @@ -1,2 +0,0 @@ -[database] -connection =mysql+pymysql://:@:3306 diff --git a/OpenStack-accounting/pyproject.toml b/OpenStack-accounting/pyproject.toml new file mode 100644 index 00000000..9fc795c9 --- /dev/null +++ b/OpenStack-accounting/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] + +[project] +name = "thecount" +version = "1.0.0" +description = "python package for collecting stfc cloud accounting data" +authors = [{name = "STFC Cloud Team", email = "cloud-support@stfc.ac.uk"}] +readme = "README.md" +requires-python = ">=3.10" +keywords = ["python", "openstack"] +classifiers = [ + "Development Status :: 5 - Stable", + "Intended Audience :: Openstack Admins :: Monitoring Tools", + "Programming Language :: Python :: 3", +] +dependencies = [ + "requests", + "sqlalchemy", + "pymysql", +] + +[project.urls] +Repository = "https://github.com/stfc/SCD-Openstack-Utils" +Documentation = "https://github.com/stfc/SCD-Openstack-Utils/OpenStack-accounting/README.md" +Issues = "https://github.com/stfc/SCD-Openstack-Utils/issues" + +# Optional dependency groups +[project.optional-dependencies] +test = [ + "black>=26.5.1", + "pylint>=4.0.6", + "pytest>=9.1.1", + "pytest-cov>=7.1.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["*.py"] +python_functions = ["test_*"] + +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' + +[tool.pylint.format] +max-line-length=118 +disable=[ + "C0114", # Missing module string - we don't need module strings for the small repo + "W1401", # Influxdb required backslashes + "R0801" # Duplicate code due to test case +] diff --git a/OpenStack-accounting/requirements.txt b/OpenStack-accounting/requirements.txt deleted file mode 100644 index 86ff0290..00000000 --- a/OpenStack-accounting/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -python3-requests -python36-sqlalchemy -python36-PyMySQL diff --git a/OpenStack-accounting/tests/conftest.py b/OpenStack-accounting/tests/conftest.py new file mode 100644 index 00000000..a1245ba3 --- /dev/null +++ b/OpenStack-accounting/tests/conftest.py @@ -0,0 +1,60 @@ +import ast +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +ROOT_TEST_DATA_FP = Path(__file__).parent / "test_data" + +# Must match the epoch in every expected.txt (1786453260). +END_TIME = datetime(2026, 8, 11, 12, 0, 0, tzinfo=timezone.utc) +START_TIME = datetime(2026, 8, 11, 11, 0, 0, tzinfo=timezone.utc) +INSTANCE = "PreProd" + + +def load_example_data(path: Path) -> list[dict[str, Any]]: + """ + load file containing example data - mock results from source + :param path: Path to example data + :returns: A list of dict to mock output of source + """ + rows = [] + for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = line.strip() + if not line or line.startswith("#"): + continue + try: + # try to read in line as a python dictionary + row = ast.literal_eval(line) + except (ValueError, SyntaxError) as exc: + raise ValueError(f"{path}:{i}: not a Python literal: {line!r}") from exc + if not isinstance(row, dict): + raise TypeError(f"{path}:{i}: expected dict, got {type(row).__name__}") + rows.append(row) + return rows + + +def load_expected_data(path: Path) -> str: + """ + Read expected sink payload from filepath + :param path: Path to example data + :returns: A string that will be passed to source + """ + lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() if ln.strip()] + return "\n".join(lines) + + + +@pytest.fixture +def mock_source(): + source = MagicMock() + return source + + +@pytest.fixture +def mock_sink(): + sink = MagicMock() + sink.instance = INSTANCE + return sink \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_cli.py b/OpenStack-accounting/tests/test_cli.py new file mode 100644 index 00000000..e3ffbb0e --- /dev/null +++ b/OpenStack-accounting/tests/test_cli.py @@ -0,0 +1,211 @@ +import argparse +from unittest.mock import patch +from datetime import datetime, timedelta, timezone + +import pytest +from conftest import ROOT_TEST_DATA_FP +from thecount.cli import setup_parser, build_config, ALL_JOBS + + +def make_args(**overrides) -> argparse.Namespace: + """ + Helper function to create an argparse.Namespace - sets up using defaults and overrides based on inputs. + used for testing build_config() function + """ + + # set config-path env variable + defaults = dict( + jobs=None, + all=False, + start_time=None, + end_time=None, + interval="3600", + dry_run=False, + config_path=ROOT_TEST_DATA_FP / "example.conf", + ) + unknown = set(overrides) - set(defaults) + if unknown: + raise AttributeError(f"no such arg: {', '.join(sorted(unknown))}") + return argparse.Namespace(**{**defaults, **overrides}) + + +@pytest.fixture(autouse=True) +def no_connections(): + """Sink/Source are built inside build_config and would open sockets.""" + with patch("thecount.cli.Sink") as sink, patch("thecount.cli.Source") as source: + yield sink, source + + +def test_setup_parser_defaults(): + """ test that defaults are set when no args passed """ + parser = setup_parser() + ns = parser.parse_args([]) + assert ns.job is None + assert ns.all is False + assert ns.start_time is None + assert ns.end_time is None + assert ns.interval == '1440' + assert ns.dry_run is False + + +def test_setup_parser_job_flag(): + """ tests that --job works correctly """ + parser = setup_parser() + + ns = parser.parse_args(["--job", "cinder", "--job", "manila"]) + assert ns.job == ["cinder", "manila"] + # splitting and validation happens downstream - so preserve incorrect values + ns = parser.parse_args(["--job", "foo,bar"]) + assert ns.job == ["foo,bar"] + # tests that you must provide a value after --job + with pytest.raises(SystemExit) as exc: + parser.parse_args(["--job"]) + assert exc.value.code == 2 + + +def test_setup_parser_all_flag(): + """ test all flag is set to true when --all provided """ + parser = setup_parser() + assert parser.parse_args(["--all"]).all is True + + +def test_setup_parser_job_and_all_are_mutually_exclusive(): + """ test that --job and --all cannot be used together """ + parser = setup_parser() + with pytest.raises(SystemExit) as exc: + parser.parse_args(["--job", "volumes", "--all"]) + assert exc.value.code == 2 + + +def test_setup_parser_unknown_argument_exits(): + """ tests that unknown arguments raise SystemExit """ + parser = setup_parser() + with pytest.raises(SystemExit) as exc: + parser.parse_args(["--unknown"]) + assert exc.value.code == 2 + + +def test_build_config_interval_below_minimum_rejected(): + """ tests build_config rejects a lower interval""" + with pytest.raises(ValueError, match="< 15"): + build_config(make_args(interval="5", all=True)) + + +def test_all_selects_every_job(no_connections): + """ tests that passing all=True arg creates an object for every available job class """ + cfg = build_config(make_args(all=True)) + job_cls = set(type(job) for job in cfg.jobs) + assert job_cls == set(ALL_JOBS.values()) + + +def test_single_job(): + """ tests that single job object is created for valid job identifier """ + cfg = build_config(make_args(jobs=["cinder"])) + assert type(cfg.jobs[0]) is ALL_JOBS["cinder"] + + +def test_job_values_split(): + cfg = build_config(make_args(jobs=["cinder", "nova"])) + job_cls = set(type(job) for job in cfg.jobs) + expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] + assert job_cls == set(expected_cls) + + +def test_job_values_no_whitespace_tolerated(): + cfg = build_config(make_args(jobs=["cinder,nova"])) + job_cls = set(type(job) for job in cfg.jobs) + expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] + assert job_cls == set(expected_cls) + + +def test_job_values_whitespace_tolerated(): + cfg = build_config(make_args(jobs=["cinder,nova", "glance"])) + job_cls = set(type(job) for job in cfg.jobs) + expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"], ALL_JOBS["glance"]] + assert job_cls == set(expected_cls) + + +def test_job_values_empty_vals_ignored(): + cfg = build_config(make_args(jobs=["cinder,,nova"])) + job_cls = set(type(job) for job in cfg.jobs) + expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] + assert job_cls == set(expected_cls) + + +def test_duplicates_run_once(): + cfg = build_config(make_args(jobs=["cinder", "nova,cinder"])) + job_cls = set(type(job) for job in cfg.jobs) + expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] + assert job_cls == set(expected_cls) + + +def test_unknown_job_named_in_error(): + with pytest.raises(ValueError, match="foo"): + build_config(make_args(jobs=["cinder,foo"])) + + +@pytest.mark.parametrize("text", ["14", "1", "0"]) +def test_interval_below_minimum_rejected(text): + """ test that interval parsing raises an error if time interval less than 15m """ + with pytest.raises(ValueError, match="< 15"): + build_config(make_args(all=True, interval=text)) + + +@pytest.mark.parametrize("text", ["foo", "5m", "d", ""]) +def test_malformed_interval_rejected(text): + """ test that malformed interval fails.""" + with pytest.raises(ValueError, match="malformed"): + build_config(make_args(all=True, interval=text)) + + +def test_start_defaults_to_now(): + """ test that start time sets now() time properly """ + before = datetime.now(timezone.utc) + cfg = build_config(make_args(all=True)) + assert before <= cfg.start_time <= datetime.now(timezone.utc) + + +def test_end_defaults_to_none(): + """ tests that end time defaults to None""" + assert build_config(make_args(all=True)).end_time is None + + +def test_explicit_times_parsed(): + """ tests that ISO 8601 formatting strings are accepted """ + cfg = build_config( + make_args(all=True, start_time="2026-01-01", end_time="2026-01-02T09:30") + ) + assert cfg.start_time == datetime(2026, 1, 1, tzinfo=timezone.utc) + assert cfg.end_time == datetime(2026, 1, 2, 9, 30, tzinfo=timezone.utc) + + +def test_naive_input_treated_as_utc(): + """tests that strings are parsed as UTC timezone by default.""" + cfg = build_config(make_args(all=True, start_time="2026-01-01T00:00")) + assert cfg.start_time.tzinfo is not None + assert cfg.start_time.utcoffset() == timedelta(0) + + +def test_start_after_end_rejected(): + """ tests to check end time is always after start time - fails if not """ + with pytest.raises(ValueError, match="before"): + build_config(make_args(all=True, start_time="2026-02-01", end_time="2026-01-01")) + + +def test_start_equal_to_end_rejected(): + """ test to check end_time and start_time are different """ + with pytest.raises(ValueError, match="before"): + build_config(make_args(all=True, start_time="2026-01-01", end_time="2026-01-01")) + + +def test_dry_run_passed_through(): + """ test to check dry_run is passed through to output config """ + assert build_config(make_args(all=True, dry_run=True)).dry_run is True + + +def test_sink_and_source_get_config_path(no_connections): + """ tests that sink and source are created with correct config path from inputs""" + sink, source = no_connections + build_config(make_args(all=True, config_path="/etc/thecount.ini")) + sink.assert_called_once_with("/etc/thecount.ini") + source.assert_called_once_with("/etc/thecount.ini") diff --git a/OpenStack-accounting/tests/test_data/cinder/exp.txt b/OpenStack-accounting/tests/test_data/cinder/exp.txt new file mode 100644 index 00000000..f8c5ecc6 --- /dev/null +++ b/OpenStack-accounting/tests/test_data/cinder/exp.txt @@ -0,0 +1,11 @@ +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=A-Scratch-Space,Department=A-Scratch-Space,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=admin,Department=admin,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=azimuth-staging,Department=azimuth-staging,CinderType=Volume,YYYY-MM=2026-08 Volumes=3,Volume_Seconds=259200,CinderGBs=777600 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=rally-xyz,Department=STFC\ Cloud,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=C-Scratch-Space,Department=C-Scratch-Space,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=D-Scratch-Space,Department=D-Scratch-Space,CinderType=Volume,YYYY-MM=2026-08 Volumes=3,Volume_Seconds=259200,CinderGBs=777600 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=test-external-1,Department=test-external-1,CinderType=Volume,YYYY-MM=2026-08 Volumes=3,Volume_Seconds=259200,CinderGBs=777600 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=E-Scratch-Space,Department=E-Scratch-Space,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=F-Scratch-Space,Department=F-Scratch-Space,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=admin,Department=admin,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=432000 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=G-Scratch-Space,Department=SCD\ Systems\ Division,CinderType=Volume,YYYY-MM=2026-08 Volumes=1,Volume_Seconds=86400,CinderGBs=8640000 1786449600 \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_data/cinder/input.txt b/OpenStack-accounting/tests/test_data/cinder/input.txt new file mode 100644 index 00000000..ba463fad --- /dev/null +++ b/OpenStack-accounting/tests/test_data/cinder/input.txt @@ -0,0 +1,11 @@ +{'AvailabilityZone': 'ceph', 'Project': 'A-Scratch-Space', 'Department': 'Default', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'admin', 'Department': 'Default', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'azimuth-staging', 'Department': 'Default', 'Volumes': 3, 'CinderType': 'Volume', 'Volume_Seconds': 259200, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'rally-xyz', 'Department': 'Default', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'C-Scratch-Space', 'Department': 'Default', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'D-Scratch-Space', 'Department': 'Default', 'Volumes': 3, 'CinderType': 'Volume', 'Volume_Seconds': 259200, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'test-external-1', 'Department': 'Default', 'Volumes': 3, 'CinderType': 'Volume', 'Volume_Seconds': 259200, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'E-Scratch-Space', 'Department': 'Default', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'F-Scratch-Space', 'Department': 'Default', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 1} +{'AvailabilityZone': 'ceph', 'Project': 'admin', 'Department': 'Default', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 5} +{'AvailabilityZone': 'ceph', 'Project': 'G-Scratch-Space', 'Department': 'SCD Systems Division', 'Volumes': 1, 'CinderType': 'Volume', 'Volume_Seconds': 86400, 'Volume_GB': 100} \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_data/glance/exp.txt b/OpenStack-accounting/tests/test_data/glance/exp.txt new file mode 100644 index 00000000..d5a98b92 --- /dev/null +++ b/OpenStack-accounting/tests/test_data/glance/exp.txt @@ -0,0 +1,17 @@ +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=179530.5600 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=706,GlanceGBSeconds=1498.6262 1786449600 +Accounting,instance=PreProd,Project=A-Scratch-Space,Department=A-Scratch-Space,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=184040.6400 1786449600 +Accounting,instance=PreProd,Project=B-Scratch-Space,Department=SCD\ Systems\ Division,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=184291.2000 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=185405.7600 1786449600 +Accounting,instance=PreProd,Project=C-Scratch-Space,Department=C-Scratch-Space,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=187297.9200 1786449600 +Accounting,instance=PreProd,Project=A-Scratch-Space,Department=A-Scratch-Space,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=198840.9600 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=218427.8400 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=218453.7600 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=218695.6800 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=7303,GlanceGBSeconds=18759.2161 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=224691.8400 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=image,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=238213.4400 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=snapshot,YYYY-MM=2026-08 Images=8,Image_Seconds=691200,GlanceGBSeconds=76129320.9600 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=RBD,GlanceType=snapshot,YYYY-MM=2026-08 Images=6,Image_Seconds=518400,GlanceGBSeconds=62208000.0000 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=RBD,GlanceType=snapshot,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=3456000.0000 1786449600 +Accounting,instance=PreProd,Project=admin,Department=admin,StorageBackend=OBJECT,GlanceType=snapshot,YYYY-MM=2026-08 Images=1,Image_Seconds=86400,GlanceGBSeconds=9481881.6000 1786449600 \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_data/glance/input.txt b/OpenStack-accounting/tests/test_data/glance/input.txt new file mode 100644 index 00000000..b19e6423 --- /dev/null +++ b/OpenStack-accounting/tests/test_data/glance/input.txt @@ -0,0 +1,17 @@ +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.0779} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 706, 'Glance_GB': 2.1227} +{'Project': 'A-Scratch-Space', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.1301} +{'Project': 'B-Scratch-Space', 'Department': 'SCD Systems Division', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.1330} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.1459} +{'Project': 'C-Scratch-Space', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.1678} +{'Project': 'A-Scratch-Space', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.3014} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.5281} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.5284} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.5312} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 7303, 'Glance_GB': 2.5687} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.6006} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'image', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 2.7571} +{'Project': 'admin', 'Department': 'Default', 'Images': 8, 'GlanceType': 'snapshot', 'StorageBackend': 'OBJECT', 'Image_Seconds': 691200, 'Glance_GB': 13.7676} +{'Project': 'admin', 'Department': 'Default', 'Images': 6, 'GlanceType': 'snapshot', 'StorageBackend': 'RBD', 'Image_Seconds': 518400, 'Glance_GB': 20.0000} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'snapshot', 'StorageBackend': 'RBD', 'Image_Seconds': 86400, 'Glance_GB': 40.0000} +{'Project': 'admin', 'Department': 'Default', 'Images': 1, 'GlanceType': 'snapshot', 'StorageBackend': 'OBJECT', 'Image_Seconds': 86400, 'Glance_GB': 109.7440} \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_data/manila/exp.txt b/OpenStack-accounting/tests/test_data/manila/exp.txt new file mode 100644 index 00000000..d3269824 --- /dev/null +++ b/OpenStack-accounting/tests/test_data/manila/exp.txt @@ -0,0 +1,7 @@ +Accounting,instance=PreProd,AvailabilityZone=nova,Project=A-Scratch-Space,Department=A-Scratch-Space,ManilaType=Share,ManilaShareType=cephfs,YYYY-MM=2026-08 Shares=1,Share_Seconds=86400,ManilaGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=nova,Project=admin,Department=admin,ManilaType=Share,ManilaShareType=cephfs,YYYY-MM=2026-08 Shares=144,Share_Seconds=12441600,ManilaGBs=1791590400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=nova,Project=rally-xyz,Department=STFC\ Cloud,ManilaType=Share,ManilaShareType=cephfs,YYYY-MM=2026-08 Shares=1,Share_Seconds=86400,ManilaGBs=86400 1786449600 +Accounting,instance=PreProd,AvailabilityZone=nova,Project=DSG,Department=SCD\ Systems\ Division,ManilaType=Share,ManilaShareType=cephfs,YYYY-MM=2026-08 Shares=2,Share_Seconds=172800,ManilaGBs=345600 1786449600 +Accounting,instance=PreProd,AvailabilityZone=nova,Project=C-Scratch-Space,Department=C-Scratch-Space,ManilaType=Share,ManilaShareType=cephfs,YYYY-MM=2026-08 Shares=1,Share_Seconds=86400,ManilaGBs=864000 1786449600 +Accounting,instance=PreProd,AvailabilityZone=nova,Project=D-Scratch-Space,Department=D-Scratch-Space,ManilaType=Share,ManilaShareType=cephfs,YYYY-MM=2026-08 Shares=1,Share_Seconds=86400,ManilaGBs=864000 1786449600 +Accounting,instance=PreProd,AvailabilityZone=nova,Project=Staging-K8s,Department=Staging-K8s,ManilaType=Share,ManilaShareType=cephfs,YYYY-MM=2026-08 Shares=6,Share_Seconds=518400,ManilaGBs=31104000 1786449600 \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_data/manila/input.txt b/OpenStack-accounting/tests/test_data/manila/input.txt new file mode 100644 index 00000000..a9158747 --- /dev/null +++ b/OpenStack-accounting/tests/test_data/manila/input.txt @@ -0,0 +1,7 @@ +{'Project': 'A-Scratch-Space', 'Department': 'Default', 'Availability_zone': 'nova', 'Share_type': 'cephfs', 'Shares': 1, 'ManilaType': 'Share', 'Share_Seconds': 86400, 'Share_GB': 1} +{'Project': 'admin', 'Department': 'Default', 'Availability_zone': 'nova', 'Share_type': 'cephfs', 'Shares': 144, 'ManilaType': 'Share', 'Share_Seconds': 12441600, 'Share_GB': 1} +{'Project': 'rally-xyz', 'Department': 'Default', 'Availability_zone': 'nova', 'Share_type': 'cephfs', 'Shares': 1, 'ManilaType': 'Share', 'Share_Seconds': 86400, 'Share_GB': 1} +{'Project': 'DSG', 'Department': 'SCD Systems Division', 'Availability_zone': 'nova', 'Share_type': 'cephfs', 'Shares': 2, 'ManilaType': 'Share', 'Share_Seconds': 172800, 'Share_GB': 1} +{'Project': 'C-Scratch-Space', 'Department': 'Default', 'Availability_zone': 'nova', 'Share_type': 'cephfs', 'Shares': 1, 'ManilaType': 'Share', 'Share_Seconds': 86400, 'Share_GB': 10} +{'Project': 'D-Scratch-Space', 'Department': 'Default', 'Availability_zone': 'nova', 'Share_type': 'cephfs', 'Shares': 1, 'ManilaType': 'Share', 'Share_Seconds': 86400, 'Share_GB': 10} +{'Project': 'Staging-K8s', 'Department': 'Default', 'Availability_zone': 'nova', 'Share_type': 'cephfs', 'Shares': 6, 'ManilaType': 'Share', 'Share_Seconds': 518400, 'Share_GB': 10} \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_data/nova/exp.txt b/OpenStack-accounting/tests/test_data/nova/exp.txt new file mode 100644 index 00000000..4112b2b2 --- /dev/null +++ b/OpenStack-accounting/tests/test_data/nova/exp.txt @@ -0,0 +1,11 @@ +Accounting,instance=PreProd,AvailabilityZone=nova,Project=A-Scratch-Space,Department=SCD\ Systems\ Division,Flavor=l3_micro,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=1,VM_Seconds=86400,Memory_MB_Seconds=1415577600,Memory_MBs=16384,VCPU_Seconds=345600,VCPUs=4,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=8640000,Root_GBs=100,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=nova,Project=B-Scratch-Space,Department=B-Scratch-Space,Flavor=l3_nano,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=2,VM_Seconds=172800,Memory_MB_Seconds=1415577600,Memory_MBs=16384,VCPU_Seconds=345600,VCPUs=4,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=8640000,Root_GBs=100,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=C-Scratch-Space,Department=C-Scratch-Space,Flavor=l3_nano,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=5,VM_Seconds=432000,Memory_MB_Seconds=3538944000,Memory_MBs=40960,VCPU_Seconds=864000,VCPUs=10,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=21600000,Root_GBs=250,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=C-Scratch-Space,Department=C-Scratch-Space,Flavor=l3_tiny,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=1,VM_Seconds=86400,Memory_MB_Seconds=2654208000,Memory_MBs=30720,VCPU_Seconds=691200,VCPUs=8,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=17280000,Root_GBs=200,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=admin,Department=admin,Flavor=dep-l2_small,FlavorPrefix=dep-l2,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=2,VM_Seconds=172800,Memory_MB_Seconds=21764505600,Memory_MBs=251904,VCPU_Seconds=5184000,VCPUs=60,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=138240000,Root_GBs=1600,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=admin,Department=admin,Flavor=g-a100sxm40gb_vgpu-2g-10gb,FlavorPrefix=g-a100sxm40gb,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=1,VM_Seconds=86400,Memory_MB_Seconds=1415577600,Memory_MBs=16384,VCPU_Seconds=345600,VCPUs=4,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=4320000,Root_GBs=50,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=admin,Department=admin,Flavor=l3_imagecreate,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=3,VM_Seconds=2331,Memory_MB_Seconds=38195766,Memory_MBs=49158,VCPU_Seconds=4662,VCPUs=6,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=46620,Root_GBs=60,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=admin,Department=admin,Flavor=l3_nano,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=55,VM_Seconds=3048505,Memory_MB_Seconds=24973352960,Memory_MBs=450560,VCPU_Seconds=6097010,VCPUs=110,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=152425250,Root_GBs=2750,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=rally-xyz,Department=STFC\ Cloud,Flavor=l3_micro,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=1,VM_Seconds=86400,Memory_MB_Seconds=1415577600,Memory_MBs=16384,VCPU_Seconds=345600,VCPUs=4,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=8640000,Root_GBs=100,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=rally-xyz,Department=STFC\ Cloud,Flavor=l3_nano,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=2,VM_Seconds=172800,Memory_MB_Seconds=1415577600,Memory_MBs=16384,VCPU_Seconds=345600,VCPUs=4,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=8640000,Root_GBs=100,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 +Accounting,instance=PreProd,AvailabilityZone=ceph,Project=azimuth-dev,Department=azimuth-dev,Flavor=l3_nano,FlavorPrefix=l3,InstanceType=core,YYYY-MM=2026-08,Charge_Unit=core VMs=1,VM_Seconds=86400,Memory_MB_Seconds=707788800,Memory_MBs=8192,VCPU_Seconds=172800,VCPUs=2,Swap_Seconds=0,Swaps=0,Root_GB_Seconds=4320000,Root_GBs=50,Ephemeral_GB_Seconds=0,Ephemeral_GBs=0,COST=0.0,GPUs=0,GPU_Seconds=0 1786449600 \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_data/nova/input.txt b/OpenStack-accounting/tests/test_data/nova/input.txt new file mode 100644 index 00000000..6a3dd31c --- /dev/null +++ b/OpenStack-accounting/tests/test_data/nova/input.txt @@ -0,0 +1,11 @@ +{'AvailabilityZone': 'nova', 'Project': 'A-Scratch-Space', 'Department': 'SCD Systems Division', 'Flavor': 'l3.micro', 'VMs': 1, 'VM_Seconds': '86400', 'testingstuff': 'something', 'Memory_MB': 16384, 'VCPU': 4, 'Swap': 0, 'Root_GB': 100, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'nova', 'Project': 'B-Scratch-Space', 'Department': 'Default', 'Flavor': 'l3.nano', 'VMs': 2, 'VM_Seconds': '172800', 'testingstuff': 'something', 'Memory_MB': 8192, 'VCPU': 2, 'Swap': 0, 'Root_GB': 50, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'C-Scratch-Space', 'Department': 'Default', 'Flavor': 'l3.nano', 'VMs': 5, 'VM_Seconds': '432000', 'testingstuff': 'something', 'Memory_MB': 8192, 'VCPU': 2, 'Swap': 0, 'Root_GB': 50, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'C-Scratch-Space', 'Department': 'Default', 'Flavor': 'l3.tiny', 'VMs': 1, 'VM_Seconds': '86400', 'testingstuff': 'something', 'Memory_MB': 30720, 'VCPU': 8, 'Swap': 0, 'Root_GB': 200, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'admin', 'Department': 'Default', 'Flavor': 'dep-l2.small', 'VMs': 2, 'VM_Seconds': '172800', 'testingstuff': 'something', 'Memory_MB': 125952, 'VCPU': 30, 'Swap': 0, 'Root_GB': 800, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'admin', 'Department': 'Default', 'Flavor': 'g-a100sxm40gb.vgpu-2g-10gb', 'VMs': 1, 'VM_Seconds': '86400', 'testingstuff': 'something', 'Memory_MB': 16384, 'VCPU': 4, 'Swap': 0, 'Root_GB': 50, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'admin', 'Department': 'Default', 'Flavor': 'l3.imagecreate', 'VMs': 3, 'VM_Seconds': '2331', 'testingstuff': 'something', 'Memory_MB': 16386, 'VCPU': 2, 'Swap': 0, 'Root_GB': 20, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'admin', 'Department': 'Default', 'Flavor': 'l3.nano', 'VMs': 55, 'VM_Seconds': '3048505', 'testingstuff': 'something', 'Memory_MB': 8192, 'VCPU': 2, 'Swap': 0, 'Root_GB': 50, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'rally-xyz', 'Department': 'Default', 'Flavor': 'l3.micro', 'VMs': 1, 'VM_Seconds': '86400', 'testingstuff': 'something', 'Memory_MB': 16384, 'VCPU': 4, 'Swap': 0, 'Root_GB': 100, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'rally-xyz', 'Department': 'Default', 'Flavor': 'l3.nano', 'VMs': 2, 'VM_Seconds': '172800', 'testingstuff': 'something', 'Memory_MB': 8192, 'VCPU': 2, 'Swap': 0, 'Root_GB': 50, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} +{'AvailabilityZone': 'ceph', 'Project': 'azimuth-dev', 'Department': 'Default', 'Flavor': 'l3.nano', 'VMs': 1, 'VM_Seconds': '86400', 'testingstuff': 'something', 'Memory_MB': 8192, 'VCPU': 2, 'Swap': 0, 'Root_GB': 50, 'Ephemeral_GB': 0, 'Per_Unit_Cost': 0, 'Charge_Unit': 'core', 'GPU_Num': None} \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_jobs.py b/OpenStack-accounting/tests/test_jobs.py new file mode 100644 index 00000000..7a404aef --- /dev/null +++ b/OpenStack-accounting/tests/test_jobs.py @@ -0,0 +1,41 @@ +import pytest + +from conftest import ROOT_TEST_DATA_FP, END_TIME, START_TIME, load_example_data, load_expected_data +from thecount.jobs import CinderAccounting, NovaAccounting, ManilaAccounting, GlanceAccounting + +JOBS = { + "cinder": CinderAccounting, + "nova": NovaAccounting, + "manila": ManilaAccounting, + "glance": GlanceAccounting, +} + +@pytest.mark.parametrize("dirname", JOBS) +def test_job_output(dirname, mock_source, mock_sink): + job_cls = JOBS[dirname] + fixture_dir = ROOT_TEST_DATA_FP / dirname + rows = load_example_data(fixture_dir / "input.txt") + expected = load_expected_data(fixture_dir / "exp.txt") + + mock_source.fetch.return_value = rows + job = job_cls() + job.run(sink=mock_sink, source=mock_source, start_time=START_TIME, end_time=END_TIME) + + mock_source.fetch.assert_called_once_with( + database=job_cls.db_name, + start_time=START_TIME, + end_time=END_TIME, + ) + mock_sink.write.assert_called_once() + actual = mock_sink.write.call_args.args[0] + + assert isinstance(actual, str), f"sink.write got {type(actual).__name__}" + assert actual.splitlines() == expected.splitlines() + + +@pytest.mark.parametrize("dirname", JOBS) +def test_dry_run_does_not_write(dirname, mock_source, mock_sink): + """ test that when dry run is true, sink.write is not called """ + mock_source.fetch.return_value = load_example_data(ROOT_TEST_DATA_FP / dirname / "input.txt") + JOBS[dirname]().run(sink=mock_sink, source=mock_source, start_time=START_TIME, end_time=END_TIME, dry_run=True) + mock_sink.write.assert_not_called() diff --git a/OpenStack-accounting/tests/test_main.py b/OpenStack-accounting/tests/test_main.py new file mode 100644 index 00000000..816df224 --- /dev/null +++ b/OpenStack-accounting/tests/test_main.py @@ -0,0 +1,177 @@ +from datetime import datetime, timedelta, timezone +from typing import List, Dict, Any +from unittest.mock import MagicMock, patch + +import pytest + +from thecount.jobs import Job +from thecount.main import run_jobs +from thecount.source import Source +from thecount.sink import Sink +from thecount.structs import RunDetails + +class StopLoop(Exception): + """Sentinel to break out of the unbounded `while True`.""" + +class TestJob(Job): + """Base for fake jobs; subclasses get their own `calls` list.""" + + def __init__(self, limit: int | None = None, name: str = "jobA"): + super().__init__() + self.name = name + self.limit = limit + self.calls = [] + + def run( + self, + source: Source, + sink: Sink, + start_time: datetime, + end_time: datetime, + dry_run=False + ): + self.calls.append( + dict(start=start_time, end=end_time, dry_run=dry_run, + sink=sink, source=source) + ) + # a way to stop an endless loop + if self.limit is not None and len(self.calls) >= self.limit: + raise StopLoop + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance): + raise NotImplementedError("this is a test class") + + +def make_job(name: str, limit: int | None = None) -> TestJob: + """ helper to create a TestJob class and set the name/looping limit """ + test_job = TestJob() + test_job.name = name + test_job.limit = limit + return test_job + + +def setup_run_details( + jobs: List[Job], + interval: timedelta, + start_time: datetime, + end_time: datetime | None, + dry_run: bool = False +) -> RunDetails: + """ helper to construct a RunDetails dataclass for tests """ + return RunDetails( + source=MagicMock(), + sink=MagicMock(), + jobs=jobs, + dry_run=dry_run, + interval=interval, + start_time=start_time, + end_time=end_time, + ) + +@pytest.fixture(autouse=True) +def stub_sleep(): + """ stubs out sleep for testing """ + with patch("thecount.__main__.time.sleep") as sleep: + yield sleep + + +def test_run_jobs_one_interval(): + """ tests run loop runs once when one interval between start and end time """ + job = make_job("foo") + interval = timedelta(hours=1) + start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) + end_time = datetime(2026, 1, 1, 1, tzinfo=timezone.utc) + run_jobs(setup_run_details([job], interval, start_time, end_time)) + assert len(job.calls) == 1 + assert job.calls[0]["start"] == start_time + assert job.calls[0]["end"] == end_time + +def test_run_jobs_multiple_contiguous_intervals(): + """ + tests run loop runs more than once when start time and end time can be + broken up into multiple intervals + """ + job = make_job("foo") + interval = timedelta(hours=1) + start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) + end_time = datetime(2026, 1, 1, 3, tzinfo=timezone.utc) + + run_jobs(setup_run_details([job], interval, start_time, end_time)) + + bounds = [(c["start"], c["end"]) for c in job.calls] + assert bounds == [ + (start_time, start_time + interval), + (start_time + interval, start_time + 2 * interval), + (start_time + 2 * interval, start_time + 3 * interval), + ] + +def test_run_jobs_partial_interval_ignored(): + """ tests that trailing time window too small for a full interval before end-time is ignored """ + job = make_job("foo") + interval = timedelta(hours=1) + start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) + end_time = datetime(2026, 1, 1, 3, 30, tzinfo=timezone.utc) + + run_jobs(setup_run_details([job], interval, start_time, end_time)) + + assert len(job.calls) == 3 + assert job.calls[-1]["end"] == start_time + 3 * interval + + +def test_run_jobs_ignores_large_interval(): + """ tests if the interval is too big between start and end time, should return nothing """ + + job = make_job("foo") + interval = timedelta(hours=1) + start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) + end_time = datetime(2026, 1, 1, 0, 30, tzinfo=timezone.utc) + + run_jobs(setup_run_details([job], interval, start_time, end_time)) + assert len(job.calls) == 0 + +def test_run_jobs_multiple_jobs_one_interval(): + """ tests that multiple jobs run per interval """ + a, b = make_job("a"), make_job("b") + interval = timedelta(hours=1) + start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) + end_time = datetime(2026, 1, 1, 2, tzinfo=timezone.utc) + + run_jobs(setup_run_details([a, b], interval, start_time, end_time)) + assert len(a.calls) == 2 and len(b.calls) == 2 + assert [c["start"] for c in a.calls] == [c["start"] for c in b.calls] + +def test_run_job_sleeps_when_interval_end_in_future(stub_sleep): + """ + tests that run_jobs() sleeps when interval end is calculated as in the future + and then calls jobs.run() after sleeping + """ + job = make_job("foo") + interval = timedelta(hours=1) + start_time = datetime.now(timezone.utc) + end_time = datetime.now(timezone.utc) + interval + + stub_sleep.side_effect = lambda s: job.calls.append("sleep") + + run_jobs(setup_run_details([job], interval, start_time, end_time)) + # should sleep once + stub_sleep.assert_called_once() + + # should sleep approx 1 hour - might be slight differences based on when it executes + assert stub_sleep.call_args.args[0] == pytest.approx(3600, abs=5) + # should be two calls - [sleep, run()] + assert len(job.calls) == 2 + # ensure sleep call was run before run() call + assert job.calls[0] == "sleep" + +def test_runs_indefinitely_without_end_time(): + """ tests that continuous looping works """ + + job = make_job("foo", limit=5) # break continuous loop at 5 loops + interval = timedelta(hours=1) + start_time = datetime.now(timezone.utc) + with pytest.raises(StopLoop): + run_jobs(setup_run_details([job], interval, start_time, None)) + + assert len(job.calls) == 5 + assert job.calls[-1]["end"] == start_time + 5 * interval \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_sink.py b/OpenStack-accounting/tests/test_sink.py new file mode 100644 index 00000000..f8853194 --- /dev/null +++ b/OpenStack-accounting/tests/test_sink.py @@ -0,0 +1,94 @@ +from unittest.mock import patch +import configparser + +import pytest + +from thecount.sink import Sink, TIMEOUT + +DATASTRING = "cpu,host=a value=1 1700000000\ncpu,host=b value=2 1700000000" + +@pytest.fixture() +def valid_config_file(tmp_path): + path = tmp_path / "t3.conf" + path.write_text( + "[sink]\n" + "host = influx.example.com\n" + "database = accounting\n" + "instance = preprod\n" + "username = user\n" + "password = pass\n", + encoding="utf-8", + ) + return path + +def test_reads_fail_when_file_not_found(tmp_path): + """ tests that file not found error raised when provided a filepath that doesn't exist """ + with pytest.raises(FileNotFoundError, match="config not found"): + Sink(str("na.conf")) + + +def test_missing_section_raises(tmp_path): + """ tests that error raised when provided a config file without relevant "sink" section """ + path = tmp_path / "t1.conf" + path.write_text("[foo]\nbar = x\n") + with pytest.raises(configparser.NoSectionError): + Sink(str(path)) + + +def test_read_missing_key_raises(tmp_path): + """ tests that error is raised when provided a config file with "sink" section but relevant keys missing """ + path = tmp_path / "t2.conf" + path.write_text("[sink]\nbar = x\n") + with pytest.raises(configparser.NoOptionError): + Sink(str(path)) + + +@patch('thecount.sink.requests.Session') +def test_write_success(mock_request_session, valid_config_file): + """ test write sets up proper session and posts correct data """ + + mock_session = mock_request_session.return_value + mock_session.post.return_value.ok = True + + + + with Sink(str(valid_config_file)) as sink: + sink.write(DATASTRING) + + mock_request_session.assert_called_once() + mock_session.post.assert_called_once_with( + "http://influx.example.com/write?db=accounting&precision=s", + data=DATASTRING, + timeout=TIMEOUT + ) + assert sink.instance == "preprod" + + +@patch('thecount.sink.requests.Session') +def test_write_fail(mock_request_session, valid_config_file): + """ raises runtime error if session.post().ok returns response that is not True """ + mock_session = mock_request_session.return_value + mock_session.post.return_value.ok = False + + with pytest.raises(RuntimeError): + with Sink(str(valid_config_file)) as sink: + sink.write(DATASTRING) + + mock_request_session.assert_called_once() + mock_session.post.assert_called_once_with( + "http://influx.example.com/write?db=accounting&precision=s", + data=DATASTRING, + timeout=TIMEOUT + ) + + +@patch('thecount.sink.requests.Session') +def test_write_empty_datastring_does_nothing(mock_request_session, valid_config_file): + """ tests that write doesn't post anything if provided an empty datastring """ + mock_session = mock_request_session.return_value + + with Sink(str(valid_config_file)) as sink: + sink.write("") + + mock_request_session.assert_called_once() + mock_session.post.assert_not_called() \ No newline at end of file diff --git a/OpenStack-accounting/tests/test_source.py b/OpenStack-accounting/tests/test_source.py new file mode 100644 index 00000000..08cd35a0 --- /dev/null +++ b/OpenStack-accounting/tests/test_source.py @@ -0,0 +1,79 @@ +from datetime import datetime, timezone +from unittest.mock import patch +import configparser + +import pytest + +from thecount.source import Source, QUERY + +START = datetime(2026, 1, 1, 9, 30, tzinfo=timezone.utc) +END = datetime(2026, 1, 2, 10, 45, tzinfo=timezone.utc) + +@pytest.fixture +def valid_config_file(tmp_path): + path = tmp_path / "t1.conf" + path.write_text("[source]\nconnection = mysql+pymysql://dbuser:pass@exampledb:3306\n") + return path + +def test_reads_connection_string(valid_config_file): + assert Source(str(valid_config_file)).conn_string == "mysql+pymysql://dbuser:pass@exampledb:3306" + + +def test_reads_fail_when_file_not_found(tmp_path): + """ tests that file not found error raised when provided a filepath that doesn't exist """ + with pytest.raises(FileNotFoundError, match="config not found"): + Source(str("na.conf")) + + +def test_missing_section_raises(tmp_path): + """ tests that error raised when provided a config file without relevant "source" section """ + path = tmp_path / "t2.conf" + path.write_text("[foo]\nbar = x\n") + with pytest.raises(configparser.NoSectionError): + Source(str(path)) + + +def test_missing_key_raises(tmp_path): + """ tests that error is raised when provided a config file with "source" section but no "connection" var """ + path = tmp_path / "t3.conf" + path.write_text("[source]\nother = x\n") + with pytest.raises(configparser.NoOptionError): + Source(str(path)) + + +def test_fetch_outside_with_block_raises(valid_config_file): + with pytest.raises(RuntimeError): + Source(str(valid_config_file)).fetch("nova", START, END) + + +@patch('thecount.source.Session') +@patch('thecount.source.sqlalchemy.create_engine') +def test_fetch_returns_rows_as_dicts(mock_create_engine, mock_session_cls, valid_config_file): + """ test fetch calls database create_engine correctly """ + + mock_session = mock_session_cls.return_value.__enter__.return_value + mock_session.execute.return_value.mappings.return_value = [ + {"foo": 1, "bar": "alpha"}, + {"foo": 2, "bar": "beta"} + ] + + with Source(str(valid_config_file)) as source: + result = source.fetch("nova", START, END) + + mock_create_engine.assert_called_once_with( + "mysql+pymysql://dbuser:pass@exampledb:3306/nova", + pool_pre_ping=True, + pool_recycle=3600, + pool_size=1, + max_overflow=0, + ) + + # assert session called with proper args + args, _ = mock_session.execute.call_args + assert args[0] is QUERY # TextClause has no useful __eq__, compare identity + assert args[1] == {"start": "2026-01-01 09:30", "end": "2026-01-02 10:45"} + + # assert results are the parsed results of calling session.execute().mappings() + assert result[0] == {"foo": 1, "bar": "alpha"} + assert result[1] == {"foo": 2, "bar": "beta"} + diff --git a/OpenStack-accounting/thecount.conf.example b/OpenStack-accounting/thecount.conf.example new file mode 100644 index 00000000..51e0882c --- /dev/null +++ b/OpenStack-accounting/thecount.conf.example @@ -0,0 +1,10 @@ +[source] +connection =mysql+pymysql://:@:3306 + +[sink] +# auth for influxdb +password=admin +username=admin +database=prod +host=localhost:8086 +instance=PreProd \ No newline at end of file diff --git a/OpenStack-accounting/thecount/__init__.py b/OpenStack-accounting/thecount/__init__.py new file mode 100644 index 00000000..45dc213e --- /dev/null +++ b/OpenStack-accounting/thecount/__init__.py @@ -0,0 +1,32 @@ +import logging +import os +import sys + +FORMAT = "%(asctime)s %(levelname)-8s [%(process)d] %(name)s: %(message)s" +LOG_FP = "/var/log/thecount/thecount.log" + +# Get log level from environment, defaulting to INFO +log_level_name = os.getenv("THECOUNT_LOG_LEVEL", "INFO").upper() +log_level = getattr(logging, log_level_name, logging.INFO) + +# Create logger +logger = logging.getLogger(__name__) +logger.setLevel(log_level) + +# Formatter +formatter = logging.Formatter(FORMAT) + +# Log to stderr +stream_handler = logging.StreamHandler(sys.stderr) +stream_handler.setLevel(log_level) +stream_handler.setFormatter(formatter) +logger.addHandler(stream_handler) + +# Log to file +if not os.path.exists(LOG_FP): + logger.warning(f"log filepath {LOG_FP} does not exist, logs aren't being stored...") +else: + file_handler = logging.FileHandler(LOG_FP) + file_handler.setLevel(log_level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) \ No newline at end of file diff --git a/OpenStack-accounting/thecount/__main__.py b/OpenStack-accounting/thecount/__main__.py new file mode 100644 index 00000000..e7b2f32d --- /dev/null +++ b/OpenStack-accounting/thecount/__main__.py @@ -0,0 +1,64 @@ +import sys +import time +import logging +from datetime import datetime + +from thecount.cli import setup_parser, build_config +from thecount.structs import RunDetails + +logger = logging.getLogger(__name__) + +def run_jobs(details: RunDetails) -> None: + with details.source, details.sink: + step_counter = 0 + current_time = details.start_time + while True: + step_counter += 1 + interval_end = current_time + details.interval + + # If there isn't a complete interval before end_time, stop. + if details.end_time is not None and interval_end > details.end_time: + logger.info( + f" {details.end_time} reached stopping..." + ) + break + + logger.info( + f"Starting Loop: {step_counter}: interval_start_time: {current_time}, interval_end_time: {interval_end}" + ) + + # Don't start the process until end of the current interval - so all data is available + now = datetime.now(current_time.tzinfo) + if interval_end > now: + logger.info(f"waiting until {interval_end.isoformat()}...") + time.sleep((interval_end - now).total_seconds()) + + # Run every job for this interval. + for job in details.jobs: + logger.info(f"running job: {type(job)}, dry_run={details.dry_run}") + job.run( + source=details.source, + sink=details.sink, + start_time=current_time, + end_time=interval_end, + dry_run=details.dry_run + ) + + # Move to the next interval. + current_time = interval_end + + +def main() -> int: + parser = setup_parser() + args = parser.parse_args() + + try: + run_details = build_config(args) + except ValueError as exc: + parser.error(str(exc)) + + run_jobs(run_details) + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/OpenStack-accounting/thecount/cli.py b/OpenStack-accounting/thecount/cli.py new file mode 100644 index 00000000..d6c12711 --- /dev/null +++ b/OpenStack-accounting/thecount/cli.py @@ -0,0 +1,160 @@ +import os +import argparse +import re +import logging +from datetime import datetime, timedelta, timezone +from typing import Optional, List + +from thecount.sink import Sink +from thecount.source import Source +from thecount.structs import RunDetails +from thecount.jobs import Job, CinderAccounting, GlanceAccounting, NovaAccounting, ManilaAccounting + +logger = logging.getLogger(__name__) + +# parse jobs +ALL_JOBS: dict[str, type[Job]] = { + "cinder": CinderAccounting, + "glance": GlanceAccounting, + "nova": NovaAccounting, + "manila": ManilaAccounting +} + +def setup_parser() -> argparse.ArgumentParser: + """ + setup parser rules, to read in and validate command line arguments + """ + parser = argparse.ArgumentParser( + prog="thecount", + description="Extract accounting records from openstack db and send them to the influx.", + epilog=( + "Only ISO 8601 time formats are accepted (2024-01-01, 2024-01-01T09:00) " + "values without timezone are treated as UTC." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + selection = parser.add_mutually_exclusive_group() + selection.add_argument( + "--job", action="append", metavar="NAME", default=None, + help="accounting job to run; repeatable, or comma-separated - see --list-jobs for full list", + ) + selection.add_argument("--all", action="store_true", help="run every accounting job") + + # default is None as current time calculated at runtime + parser.add_argument( + "--start-time", metavar="TIME", default=None, + help="(optional) start of the range ISO 8601 format( yyyy-mm-ddTHH:MM:SS ), " + "if not given, --start-time will be set as the current time", + ) + parser.add_argument( + "--end-time", metavar="TIME", default=None, + help="end of the range, if in the future, script will complete once that range is reached" + " if not given, will run from given --start-time and keep running forever until killed (default: None)" + " ISO 8601 format( yyyy-mm-ddTHH:MM:SS )", + ) + parser.add_argument( + "--interval", metavar="DURATION", default="1440", + help="size of window (in minutes) for each accounting extraction period - USAGE: 1440, 60, 15 " + "value must be >=15, default is 1440 (1 day)", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="print the records to be sent instead of sending to influxdb", + ) + parser.add_argument( + "--config-path", metavar="THE_COUNT_CONFIG_FILE", default=os.environ.get("$THE_COUNT_CONFIG_FILE"), + help="config file with source and sink credentials (default: $THE_COUNT_CONFIG_FILE)", + ) + return parser + +def _split_jobs(values: Optional[List[str]]) -> list[str]: + """ + Helper function that splits comma-spaced job names into a list of distinct jobs to run + global mapping of name to job class is specified in ALL_JOBS const variable at the top + of this file + :param values: comma-spaced list of job names + :return: list of distinct job classes corresponding to given job nmaes + """ + if not values: + return [] + + names: list[str] = [] + for value in values: + parts = [part.strip() for part in value.split(",") if part.strip()] + names.extend(parts) + # removes duplicates whilst preserving order + return list(dict.fromkeys(names)) + + +def _as_utc(dt: datetime) -> datetime: + """ + Helper string to convert datetime to UTC if timezone given + dt: python datetime object + :returns: equivalent python datetime object converted to the equivalent UTC timezone time + """ + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + +def build_config(args: argparse.Namespace) -> RunDetails: + """ + Validates and parses input values and creates a RunDetails dataclass to hold configuration details on + how to run various accounting jobs + :params args: argparse Namespace containing args passed in via CLI + :returns: a RunDetails struct containing parameters on what accounting jobs to run and how to run them + """ + + names = list(ALL_JOBS) + if not args.all: + names = _split_jobs(args.jobs) + if not names: + raise ValueError("specify --job NAME (repeatable) or --all") + unknown = [name for name in names if name not in ALL_JOBS] + if unknown: + raise ValueError(f"unknown job(s): {', '.join(unknown)}. Available: {ALL_JOBS}") + requested_jobs = [ALL_JOBS[name]() for name in names] + + # times and durations + try: + interval = timedelta(minutes=int(args.interval)) + except ValueError as e: + raise ValueError(f"malformed interval {args.interval}") from e + if interval < timedelta(minutes=15): + raise ValueError(f"interval {args.interval} set at < 15 which is likely to cause DB slowdowns") + + if args.end_time: + try: + end = _as_utc(datetime.fromisoformat(args.end_time)) + except ValueError: + raise ValueError( + f"unable to parse --end-time {args.end_time}, accepted format ISO 8601'" + ) + else: + end = None + logger.info("--end-time not set, running continuously...") + + if args.start_time: + try: + start = _as_utc(datetime.fromisoformat(args.start_time)) + except ValueError: + raise ValueError( + f"unable to parse --start-time {args.start_time}, accepted format ISO 8601'" + ) + else: + start = datetime.now(timezone.utc) + logger.info(f"--start-time set to {start}") + + if end and start >= end: + raise ValueError( + f"--start-time ({start.isoformat()}) must be before " + f"--end-time ({end.isoformat()})" + ) + + return RunDetails( + jobs=requested_jobs, + sink=Sink(args.config_path), + source=Source(args.config_path), + dry_run=args.dry_run, + interval=interval, + start_time=start, + end_time=end, + ) \ No newline at end of file diff --git a/OpenStack-accounting/thecount/jobs.py b/OpenStack-accounting/thecount/jobs.py new file mode 100644 index 00000000..2bbee426 --- /dev/null +++ b/OpenStack-accounting/thecount/jobs.py @@ -0,0 +1,228 @@ +from typing import List, Dict, Any +import logging +from abc import ABC, abstractmethod +from datetime import datetime + +from thecount.sink import Sink +from thecount.source import Source + +logger = logging.getLogger(__name__) + +class Job(ABC): + """ + Abstract base class for all accounting jobs + """ + db_name = "" + + def run( + self, + source: Source, + sink: Sink, + start_time: datetime, + end_time: datetime, + dry_run=False + ) -> None: + """ + function that fetches data from the source, parses and transforms it, then passes it to the sink + source: a Source object is configured to fetch accounting data + - implements the fetch() method + sink: a Sink object is configured to write transformed/parsed accounting data somewhere + - implements the write() method + start_time: python datetime - the start time of the job + end_time: python datetime - the end time of the job + dry_run: boolean - whether to run the job in dry run mode - i.e. print to console instead of sending + it to source (useful for debugging) + """ + rows = source.fetch( + database=self.db_name, + start_time=start_time, + end_time=end_time, + ) + out = self._transform(rows, end_time, sink.instance) + print(out) if dry_run else sink.write(out) + + @staticmethod + def get_department(result: Dict[str, Any]) -> str: + """ + helper function returns an appropriate accounting department for each project from source + """ + department = result["Department"] + if "rally" in result["Project"]: + department = "STFC Cloud" + elif "default" in result["Department"].casefold(): + department = result["Project"] + return department + + @staticmethod + @abstractmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ABSTRACT CLASS - to be implemented by subclasses""" + pass + + +class CinderAccounting(Job): + db_name="cinder" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform cinder data from source to get total amount of usage (GB seconds) for block-storage per project + :rows: list of dictionaries containing cinder data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to state which source the data came from + """ + datastring = "" + for row in rows: + department = Job.get_department(row).replace(' ', r'\ ') + project = row['Project'].replace(' ', r'\ ') + + datastring += ( + f"Accounting" + f",instance={instance}" + f",AvailabilityZone={row['AvailabilityZone']}" + f",Project={project}" + f",Department={department}" + f",CinderType={row['CinderType']}" + f",YYYY-MM={end_time.strftime('%Y-%m')}" + + f" Volumes={row['Volumes']}" + f",Volume_Seconds={row['Volume_Seconds']}" + f",CinderGBs={row['Volume_GB'] * row['Volume_Seconds'] * row['Volumes']}" + + f" {end_time.timestamp():.0f}\n" + ) + return datastring + + +class GlanceAccounting(Job): + db_name="glance" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform glance data from source to get total image/snapshot storage usage (GB seconds) per project + :rows: list of dictionaries containing glance data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to state which source the data came from + """ + datastring = "" + for row in rows: + department = Job.get_department(row).replace(' ', r'\ ') + project = row['Project'].replace(' ', r'\ ') + + datastring += ( + f"Accounting" + f",instance={instance}" + f",Project={project}" + f",Department={department}" + f",StorageBackend={row['StorageBackend']}" + f",GlanceType={row['GlanceType']}" + f",YYYY-MM={end_time.strftime('%Y-%m')}" + + f" Images={row['Images']}" + f",Image_Seconds={row['Image_Seconds']}" + # previous data had 4 decimal places of accuracy + f",GlanceGBSeconds={row['Glance_GB'] * row['Image_Seconds'] * row['Images']:.4f}" + + f" {end_time.timestamp():.0f}\n" + ) + return datastring + + +class ManilaAccounting(Job): + db_name="manila" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform manila data from source to get the total shared network storage used (GB seconds) per project + :rows: list of dictionaries containing manila data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to indicate which source the data came from + """ + datastring = "" + for row in rows: + department = Job.get_department(row).replace(' ', r'\ ') + project = row['Project'].replace(' ', r'\ ') + + datastring += ( + f"Accounting" + f",instance={instance}" + f",AvailabilityZone={row['Availability_zone']}" + f",Project={project}" + f",Department={department}" + f",ManilaType={row['ManilaType']}" + f",ManilaShareType={row['Share_type']}" + f",YYYY-MM={end_time.strftime('%Y-%m')}" + + f" Shares={row['Shares']}" + f",Share_Seconds={row['Share_Seconds']}" + f",ManilaGBs={row['Share_GB'] * row['Share_Seconds'] * row['Shares']}" + + f" {end_time.timestamp():.0f}\n" + ) + return datastring + + +class NovaAccounting(Job): + db_name="nova" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform nova data from source to get total cost for running VMs on the cloud per project + - including flavor cost calculations and GPU cost calculations + :rows: list of dictionaries containing nova data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to indicate which source the data came from + """ + datastring = "" + for row in rows: + department = Job.get_department(row).replace(' ', r'\ ') + project = row['Project'].replace(' ', r'\ ') + + instancetype = row["Charge_Unit"] + gpu_num = int(row["GPU_Num"] or 0) + vm_seconds = int(row["VM_Seconds"]) + vms = int(row["VMs"]) + vcpu = int(row["VCPU"]) + swap = int(row["Swap"]) + root_gb = int(row["Root_GB"]) + ephemeral_gb = int(row["Ephemeral_GB"]) + cost = float( + gpu_num * vm_seconds * float(row["Per_Unit_Cost"]) / 3600 + if gpu_num > 0 + else vcpu * vm_seconds * float(row["Per_Unit_Cost"]) / 3600 + ) + + datastring += ( + f"Accounting" + f",instance={instance}" + f",AvailabilityZone={row['AvailabilityZone']}" + f",Project={project}" + f",Department={department}" + f",Flavor={row['Flavor'].replace('.', '_')}" + f",FlavorPrefix={row['Flavor'].split('.')[0]}" + f",InstanceType={instancetype}" + f",YYYY-MM={end_time:%Y-%m}" + f",Charge_Unit={row['Charge_Unit']}" + + f" VMs={vms}" + f",VM_Seconds={vm_seconds}" + f",Memory_MB_Seconds={row['Memory_MB'] * vm_seconds}" + f",Memory_MBs={row['Memory_MB'] * vms}" + f",VCPU_Seconds={vcpu * vm_seconds}" + f",VCPUs={vcpu * vms}" + f",Swap_Seconds={swap * vm_seconds}" + f",Swaps={swap * vms}" + f",Root_GB_Seconds={root_gb * vm_seconds}" + f",Root_GBs={root_gb * vms}" + f",Ephemeral_GB_Seconds={ephemeral_gb * vm_seconds}" + f",Ephemeral_GBs={ephemeral_gb * vms}" + f",COST={cost}" + f",GPUs={gpu_num * vms}" + f",GPU_Seconds={gpu_num * vm_seconds}" + + f" {end_time.timestamp():.0f}\n" + ) + return datastring diff --git a/OpenStack-accounting/thecount/sink.py b/OpenStack-accounting/thecount/sink.py new file mode 100644 index 00000000..4ba5e81a --- /dev/null +++ b/OpenStack-accounting/thecount/sink.py @@ -0,0 +1,67 @@ +import logging +from configparser import ConfigParser + +import requests +from typing import Optional + +logger = logging.getLogger(__name__) + +TIMEOUT = (5, 30) # (connect, read) + + +class Sink: + def __init__(self, config_fp: str): + """ constructor method """ + self._session: Optional[requests.Session] = None + self._load_config(config_fp) + + def _load_config(self, config_fp: str) -> None: + """ + helper function to load in thecount config file and extract sink-related config info + :param config_fp: path to thecount config file + """ + parser = ConfigParser(interpolation=None) + if not parser.read(config_fp, encoding="utf-8"): + raise FileNotFoundError(f"config not found: {config_fp}") + host = parser.get("sink", "host") + database = parser.get("sink", "database") + self.instance = parser.get("sink", "instance") + self.url = f"http://{host}/write?db={database}&precision=s" + self._auth = (parser.get("sink", "username"), parser.get("sink", "password")) + + def __enter__(self) -> "Sink": + """ + Sink is managed via context manager - when context is opened, create and maintain a requests session + :return: self + """ + self._session = requests.Session() + self._session.auth = self._auth + logger.debug("sink session opened for %s", self.url) + return self + + def __exit__(self, *exc) -> bool: + """ + Sink is managed via context manager - when context is closed, close the internal requests session + """ + if self._session is not None: + self._session.close() + self._session = None + return False + + def write(self, data_string: str) -> None: + """ + write data to sink influxdb + :param data_string: string - influx compatible data to pass via POST request to influxdb + """ + if self._session is None: + raise RuntimeError("Sink.write() called outside a `with` block") + if not data_string.strip(): + logger.info("nothing to write") + return + + response = self._session.post(self.url, data=data_string, timeout=TIMEOUT) + if not response.ok: + raise RuntimeError( + f"influx write failed {response.status_code}: {response.text[:500]}" + ) + logger.info("wrote %d lines", len(data_string.splitlines())) \ No newline at end of file diff --git a/OpenStack-accounting/thecount/source.py b/OpenStack-accounting/thecount/source.py new file mode 100644 index 00000000..5d3510f2 --- /dev/null +++ b/OpenStack-accounting/thecount/source.py @@ -0,0 +1,101 @@ +import configparser +import logging +from datetime import datetime +from typing import Optional, Dict + +import sqlalchemy +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +QUERY = sqlalchemy.text("CALL get_accounting_data(:start, :end)") + + +class Source: + """ + Reads accounting data from the OpenStack MySQL databases. + + Engines are created lazily on first use of each database and reused for + every subsequent window, then disposed when the block exits. + """ + + def __init__(self, config_fp: str): + """ constructor class """ + self._engines: Optional[Dict[str, sqlalchemy.Engine]] = None + self._load_config(config_fp) + + def _load_config(self, config_fp: str) -> None: + """ + Helper function to read thecount config file and extract source-related config + constructs a connection string. Done in __init__ so issues with the config + path fails at startup rather than on the first window. + :param config_fp: path to config file + """ + parser = configparser.ConfigParser() + if not parser.read(config_fp, encoding="utf-8"): + raise FileNotFoundError(f"config not found: {config_fp}") + self.conn_string = parser.get("source", "connection") + + def __enter__(self) -> "Source": + """ + Source is managed via context manager, when context is opened, set engines to empty dict {} + forcing a new sqlalchemy engine to be instantiated and stored for each DB opened + :return: self + """ + if self._engines is not None: + raise RuntimeError("Source is already open") + self._engines = {} + return self + + def __exit__(self, *exc) -> bool: + """ + Source is managed via context manager, when context is closed, disposes of any instantiated engines + """ + for database, engine in self._engines.items(): + logger.debug("disposing engine for %s", database) + engine.dispose() + self._engines = None + return False + + def _engine(self, database: str) -> sqlalchemy.Engine: + """ + Engines live for the whole run, so connections will sit idle between + windows. pool_pre_ping revives ones the server has dropped; + pool_recycle retires them before MySQL's wait_timeout does. + + instantiating and maintaining engines reduces operational overhead and memory + needed for continuous processes + :param database: database to open from source sql db + """ + if database not in self._engines: + logger.debug("creating engine for %s", database) + self._engines[database] = sqlalchemy.create_engine( + f"{self.conn_string}/{database}", + pool_pre_ping=True, + pool_recycle=3600, + pool_size=1, + max_overflow=0, + ) + return self._engines[database] + + def fetch( + self, database: str, start_time: datetime, end_time: datetime + ) -> list[dict]: + """ + Call the accounting sql procedure for a single interval. + + :param database: database to query, e.g. "nova" + :param start_time: interval start time + :param end_time: interval end time + :returns: results as list of dicts + """ + if self._engines is None: + raise RuntimeError("Source.fetch() called outside a `with` block") + + start = start_time.strftime("%Y-%m-%d %H:%M") + end = end_time.strftime("%Y-%m-%d %H:%M") + logger.debug("get_accounting_data(%s, %s) on %s", start, end, database) + + with Session(self._engine(database)) as session: + result = session.execute(QUERY, {"start": start, "end": end}) + return [dict(row) for row in result.mappings()] diff --git a/OpenStack-accounting/thecount/structs.py b/OpenStack-accounting/thecount/structs.py new file mode 100644 index 00000000..5a5dedc1 --- /dev/null +++ b/OpenStack-accounting/thecount/structs.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import List, Optional + +from datetime import datetime, timedelta +from thecount.sink import Sink +from thecount.source import Source +from thecount.jobs import Job + +@dataclass +class RunDetails: + """ + create a dataclass to hold accounting run details + :param jobs: which jobs to run + :sink: Sink object used to write to influxdb + :source: Source object used to read data from openstack sql db + :interval: timedelta object - size of window to collate accounting info between + :dry_run: bool - if True, will not write to Sink, output to console instead - for testing + """ + jobs: List[Job] + sink: Sink + source: Source + interval: timedelta + start_time: datetime + end_time: Optional[datetime] + dry_run: bool = False diff --git a/OpenStack-accounting/usr/local/sbin/now-accounting.sh b/OpenStack-accounting/usr/local/sbin/now-accounting.sh deleted file mode 100644 index df412bdb..00000000 --- a/OpenStack-accounting/usr/local/sbin/now-accounting.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2023 United Kingdom Research and Innovation - -# helper script for calculating accounting for today - -enddate=`date +"%Y-%m-%d %H:%M"` -startdate=`date -d "$enddate 1 day ago" +"%Y-%m-%d %H:%M"` -enddateepoch=`date -d "$enddate" +%s` -startdateepoch=`date -d "$startdate" +%s` - -echo $startdate -echo $enddate -for extractor in $(ls /usr/local/sbin/*extract_accounting.py); -do - $extractor "$startdate" "$enddate"; -done diff --git a/OpenStack-accounting/usr/local/sbin/past-accounting.sh b/OpenStack-accounting/usr/local/sbin/past-accounting.sh deleted file mode 100644 index 6ef26093..00000000 --- a/OpenStack-accounting/usr/local/sbin/past-accounting.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2023 United Kingdom Research and Innovation - -# helper script for regenerating daily accounting over a period of time - -startdate=$1 -enddate=$2 -enddateepoch=`date -d "$enddate" +%s` -startdateepoch=`date -d "$startdate" +%s` - -endtime=$startdate -starttime=`date -d "$startdate" +"%Y-%m-%d %H:%M"` -endtimeepoch=`date -d "$endtime" +%s` - -while [[ $endtimeepoch -lt $enddateepoch ]]; -do - starttime=`date -d "$starttime" +"%Y-%m-%d %H:%M"` - starttimeepoch=`date -d "$starttime" +%s` - endtime=`date -d "$starttime 1 day" +"%Y-%m-%d %H:%M"` - endtimeepoch=`date -d "$endtime" +%s` - echo $starttime - echo $endtime - for extractor in $(ls /usr/local/bin/*extract_accounting.py); - do - $extractor "$starttime" "$endtime"; - done - starttime=$endtime -done; diff --git a/OpenStack-accounting/uv.lock b/OpenStack-accounting/uv.lock new file mode 100644 index 00000000..885776e3 --- /dev/null +++ b/OpenStack-accounting/uv.lock @@ -0,0 +1,791 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072, upload-time = "2026-07-22T11:38:14.299Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393, upload-time = "2026-07-22T12:26:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750, upload-time = "2026-07-22T12:28:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5c/53d6b94742a6f1ee1877c7ff76262c909e137f3f7383ce96a8ab78e1ae31/greenlet-3.5.4-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52", size = 629659, upload-time = "2026-07-22T12:43:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920, upload-time = "2026-07-22T11:51:06.83Z" }, + { url = "https://files.pythonhosted.org/packages/f2/34/957fc5577180ef2f57be82580ee1f59fdefad4f6c623c7d5e1b6980a76fb/greenlet-3.5.4-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c", size = 425580, upload-time = "2026-07-22T12:39:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262, upload-time = "2026-07-22T12:25:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906, upload-time = "2026-07-22T11:51:08.627Z" }, + { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036, upload-time = "2026-07-22T11:38:30.073Z" }, + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, +] + +[[package]] +name = "pymysql" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/bc/1c6a92f385940f727daeecf3bacaf186e03875dff57197801046c583bcf0/pymysql-1.2.0.tar.gz", hash = "sha256:6c7b17ca686988104d7426c27895b455cdeea3e9d3ceb1270f0c3704fead8c33", size = 49021, upload-time = "2026-05-19T08:26:22.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/bd/2534e130295c8cfd4f0a2e31623baab7502278f1e97bcfe61db75656a77f/pymysql-1.2.0-py3-none-any.whl", hash = "sha256:62169ce6d5510f08e140c5e7990ee884a9764024e4a9a27b2cc11f1099322ae0", size = 45716, upload-time = "2026-05-19T08:26:20.974Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", size = 2162087, upload-time = "2026-06-15T16:05:15.795Z" }, + { url = "https://files.pythonhosted.org/packages/6c/22/f19552eb7876774d50cfd025337ef5d67acc10cd8f29adab7716cf47c352/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", size = 3244579, upload-time = "2026-06-15T16:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", size = 3243515, upload-time = "2026-06-15T16:12:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/74/c6/5900ec624fab3360aa2ec59b99bb2046dd79799e310bb78a0514eaa4038e/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", size = 3195492, upload-time = "2026-06-15T16:10:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2ee3c4e1ac4fd22309349823fe13f33febeab1a71db1d7e9d60293a07dcb/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", size = 3215782, upload-time = "2026-06-15T16:12:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1c/3bd72c341f1cb5faed5a7457ea840228a46be51cfbaf31a9db72fc963f11/sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", size = 2122119, upload-time = "2026-06-15T16:13:26.915Z" }, + { url = "https://files.pythonhosted.org/packages/2a/63/b6dfdd646abf91c3bedb13727226a5e765e5f8365e898d43818e6672fa46/sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", size = 2145158, upload-time = "2026-06-15T16:13:28.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "thecount" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "pymysql" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] + +[package.optional-dependencies] +test = [ + { name = "black" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'test'", specifier = ">=26.5.1" }, + { name = "pylint", marker = "extra == 'test'", specifier = ">=4.0.6" }, + { name = "pymysql" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.1.1" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.1.0" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] +provides-extras = ["test"] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From e09d03c9bf7693dba5a7d327369a191e428f1fb8 Mon Sep 17 00:00:00 2001 From: AnishMudaraddi Date: Fri, 21 Aug 2026 12:09:57 +0100 Subject: [PATCH 03/11] remove old code --- OpenStack-accounting/_old/__init__.py | 0 OpenStack-accounting/_old/accountinglib.py | 101 ------------------ .../_old/cinder_extract_accounting.py | 56 ---------- .../_old/glance_extract_accounting.py | 56 ---------- .../_old/manila_extract_accounting.py | 57 ---------- .../_old/nova_extract_accounting.py | 95 ---------------- 6 files changed, 365 deletions(-) delete mode 100644 OpenStack-accounting/_old/__init__.py delete mode 100644 OpenStack-accounting/_old/accountinglib.py delete mode 100644 OpenStack-accounting/_old/cinder_extract_accounting.py delete mode 100644 OpenStack-accounting/_old/glance_extract_accounting.py delete mode 100644 OpenStack-accounting/_old/manila_extract_accounting.py delete mode 100644 OpenStack-accounting/_old/nova_extract_accounting.py diff --git a/OpenStack-accounting/_old/__init__.py b/OpenStack-accounting/_old/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/OpenStack-accounting/_old/accountinglib.py b/OpenStack-accounting/_old/accountinglib.py deleted file mode 100644 index b7d21849..00000000 --- a/OpenStack-accounting/_old/accountinglib.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/python3 -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2023 United Kingdom Research and Innovation -import time -import datetime - -# from datetime import datetime,time -import json -import requests -import sys -import sqlalchemy -import logging -from sqlalchemy.sql import select -from sqlalchemy.orm import sessionmaker -import configparser - - -CONFIG_FP = "/etc/thecount/thecount.conf" - -def get_logger(component): - logging.basicConfig( - filename="/var/log/thecount.log", - format=f"%(asctime)s {component} %(message)s", - filemode="a", - ) - logger = logging.getLogger() - logger.setLevel(logging.INFO) - return logger - - -def ifnull(var, val): - """Returns the second argument if the first argument is Null/None""" - if var is None: - return val - return var - - -def project_to_department(result): - """Returns an appropriate department for a project""" - if "rally" in result["Project"]: - department = "STFC Cloud" - elif "efault" in result["Department"]: - department = result["Project"] - else: - department = result["Department"] - return department - - -def send_to_influx(datastring, logger): - """Takes a datastring formatted to send to InfluxDBs rest api. Loads necessary config, sends and returns the response""" - # Read from config file - influx_parser = configparser.ConfigParser() - try: - influx_parser.read(CONFIG_FP) - except Exception as e: - logger.info(f"Unable to read from influx config file - {str(exp)}") - sys.exit(1) - try: - host = influx_parser.get("db", "host") - database = influx_parser.get("db", "database") - username = influx_parser.get("auth", "username") - password = influx_parser.get("auth", "password") - instance = influx_parser.get("cloud", "instance") - except Exception as exp: - logger.info(f"Unable to parse influx config file - {str(exp)}") - sys.exit(1) - finaldatastring = datastring.replace( - "Accounting,", "Accounting,instance=" + instance + "," - ) - logger.info(finaldatastring) - url = f"http://{host}/write?db={database}&precision=s" - response = requests.post(url, data=finaldatastring, auth=(username, password)) - return response - - -def get_accounting_data(database, starttime, endtime, logger): - """Takes a db name and a start and end time as arguments. Loads db config, creates a db connection and runs a stored procedure. Returns the results of the stored procedure""" - thecount_parser = configparser.RawConfigParser(strict=False) - - try: - thecount_parser.read(CONFIG_FP) - except Exceptions as exp: - logger.info(f"Unable to read from thecount config file - {str(exp)}") - sys.exit(1) - - try: - connectionstring = ( - thecount_parser.get("database", "connection") + "/" + database - ) - except Exceptions as exp: - logger.info(f"Unable to parse thecount config file - {str(exp)}") - sys.exit(1) - - engine = sqlalchemy.create_engine(connectionstring, encoding="utf-8") - connection = engine.connect() - sess = sessionmaker(bind=engine)() - query = f'call get_accounting_data( "{starttime}","{endtime}")' - - logger.info(query) - results = sess.execute(query, {"p1": starttime, "p2": endtime}) - return results diff --git a/OpenStack-accounting/_old/cinder_extract_accounting.py b/OpenStack-accounting/_old/cinder_extract_accounting.py deleted file mode 100644 index 0f54b6d4..00000000 --- a/OpenStack-accounting/_old/cinder_extract_accounting.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/python3 -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2023 United Kingdom Research and Innovation -import accountinglib - -import sys -import time -import datetime - - -def main(): - """Processes accounting from Cinder""" - nowtime = time.localtime() - logger = accountinglib.get_logger("cinder") - - logger.info("Cinder Accounting run start") - starttime = sys.argv[1] - logger.info("Start Time = " + starttime) - endtime = sys.argv[2] - logger.info("End Time = " + endtime) - endyyyymm = datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").strftime("%Y-%m") - endtimestamp = time.mktime( - datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").timetuple() - ) - - results = accountinglib.get_accounting_data("cinder", starttime, endtime, logger) - datastring = "" - logger.info(results) - for result in results: - logger.info(result) - department = accountinglib.project_to_department(result) - - datastring += "Accounting" - - datastring += ",AvailabilityZone=" + result["AvailabilityZone"] - datastring += ",Project=" + result["Project"].replace(" ", "\ ") - datastring += ",Department=" + department.replace(" ", "\ ") - datastring += ",CinderType=" + result["CinderType"] - datastring += ",YYYY-MM=" + endyyyymm - datastring += " Volumes=" + str(result["Volumes"]) - datastring += ",Volume_Seconds=" + str(result["Volume_Seconds"]) - datastring += ",CinderGBs=" + str( - result["Volume_GB"] * result["Volume_Seconds"] * result["Volumes"] - ) - - datastring += " " + str(int(endtimestamp)) - datastring += "\n" - - r = accountinglib.send_to_influx(datastring, logger) - - logger.info(r.text) - logger.info(r) - - -if __name__ == "__main__": - main() diff --git a/OpenStack-accounting/_old/glance_extract_accounting.py b/OpenStack-accounting/_old/glance_extract_accounting.py deleted file mode 100644 index de69bb99..00000000 --- a/OpenStack-accounting/_old/glance_extract_accounting.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/python3 -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2023 United Kingdom Research and Innovation -import accountinglib - -import sys -import time -import datetime - - -def main(): - """Processes accounting from Glance""" - nowtime = time.localtime() - logger = accountinglib.get_logger("glance") - - logger.info("Glance Accounting run start") - starttime = sys.argv[1] - logger.info("Start Time = " + starttime) - endtime = sys.argv[2] - logger.info("End Time = " + endtime) - endyyyymm = datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").strftime("%Y-%m") - endtimestamp = time.mktime( - datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").timetuple() - ) - - results = accountinglib.get_accounting_data("glance", starttime, endtime, logger) - datastring = "" - logger.info(results) - for result in results: - logger.info(result) - department = accountinglib.project_to_department(result) - - datastring += "Accounting" - - datastring += ",Project=" + result["Project"].replace(" ", "\ ") - datastring += ",Department=" + department.replace(" ", "\ ") - datastring += ",StorageBackend=" + result["StorageBackend"] - datastring += ",GlanceType=" + result["GlanceType"] - datastring += ",YYYY-MM=" + endyyyymm - datastring += " Images=" + str(result["Images"]) - datastring += ",Image_Seconds=" + str(result["Image_Seconds"]) - datastring += ",GlanceGBSeconds=" + str( - result["Glance_GB"] * result["Image_Seconds"] * result["Images"] - ) - - datastring += " " + str(int(endtimestamp)) - datastring += "\n" - - r = accountinglib.send_to_influx(datastring, logger) - - logger.info(r.text) - logger.info(r) - - -if __name__ == "__main__": - main() diff --git a/OpenStack-accounting/_old/manila_extract_accounting.py b/OpenStack-accounting/_old/manila_extract_accounting.py deleted file mode 100644 index 057d0122..00000000 --- a/OpenStack-accounting/_old/manila_extract_accounting.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/python3 -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2023 United Kingdom Research and Innovation -import accountinglib - -import sys -import time -import datetime - - -def main(): - """Processes accounting from Manila""" - nowtime = time.localtime() - logger = accountinglib.get_logger("manila") - - logger.info("Manila Accounting run start") - starttime = sys.argv[1] - logger.info("Start Time = " + starttime) - endtime = sys.argv[2] - logger.info("End Time = " + endtime) - endyyyymm = datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").strftime("%Y-%m") - endtimestamp = time.mktime( - datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").timetuple() - ) - - results = accountinglib.get_accounting_data("manila", starttime, endtime, logger) - datastring = "" - logger.info(results) - for result in results: - logger.info(result) - department = accountinglib.project_to_department(result) - - datastring += "Accounting" - - datastring += ",AvailabilityZone=" + result["Availability_zone"] - datastring += ",Project=" + result["Project"].replace(" ", "\ ") - datastring += ",Department=" + department.replace(" ", "\ ") - datastring += ",ManilaType=" + result["ManilaType"] - datastring += ",ManilaShareType=" + result["Share_type"] - datastring += ",YYYY-MM=" + endyyyymm - datastring += " Shares=" + str(result["Shares"]) - datastring += ",Share_Seconds=" + str(result["Share_Seconds"]) - datastring += ",ManilaGBs=" + str( - result["Share_GB"] * result["Share_Seconds"] * result["Shares"] - ) - - datastring += " " + str(int(endtimestamp)) - datastring += "\n" - - r = accountinglib.send_to_influx(datastring, logger) - - logger.info(r.text) - logger.info(r) - - -if __name__ == "__main__": - main() diff --git a/OpenStack-accounting/_old/nova_extract_accounting.py b/OpenStack-accounting/_old/nova_extract_accounting.py deleted file mode 100644 index de5d2d7a..00000000 --- a/OpenStack-accounting/_old/nova_extract_accounting.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/python3 -# SPDX-License-Identifier: Apache-2.0 -# Copyright (c) 2023 United Kingdom Research and Innovation -import accountinglib - -import sys -import time -import datetime - - -def main(): - """Processes accounting from Nova""" - nowtime = time.localtime() - logger = accountinglib.get_logger("nova") - - logger.info("Nova Accounting run start") - starttime = sys.argv[1] - logger.info("Start Time = " + starttime) - endtime = sys.argv[2] - logger.info("End Time = " + endtime) - endyyyymm = datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").strftime("%Y-%m") - endtimestamp = time.mktime( - datetime.datetime.strptime(endtime, "%Y-%m-%d %H:%M").timetuple() - ) - - results = accountinglib.get_accounting_data("nova", starttime, endtime, logger) - datastring = "" - for result in results: - department = accountinglib.project_to_department(result) - - instancetype = result["Charge_Unit"] - - datastring += "Accounting" - - datastring += ",AvailabilityZone=" + result["AvailabilityZone"] - datastring += ",Project=" + result["Project"].replace(" ", "\ ") - datastring += ",Department=" + department.replace(" ", "\ ") - datastring += ",Flavor=" + result["Flavor"].replace(".", "_") - datastring += ",FlavorPrefix=" + result["Flavor"].split(".")[0] - datastring += ",InstanceType=" + instancetype - datastring += ",YYYY-MM=" + endyyyymm - datastring += ",Charge_Unit=" + result["Charge_Unit"] - datastring += " VMs=" + str(result["VMs"]) - datastring += ",VM_Seconds=" + str(result["VM_Seconds"]) - datastring += ",Memory_MB_Seconds=" + str( - result["Memory_MB"] * result["VM_Seconds"] - ) - datastring += ",Memory_MBs=" + str(result["Memory_MB"] * result["VMs"]) - datastring += ",VCPU_Seconds=" + str(result["VCPU"] * result["VM_Seconds"]) - datastring += ",VCPUs=" + str(result["VCPU"] * result["VMs"]) - datastring += ",Swap_Seconds=" + str(result["Swap"] * result["VM_Seconds"]) - datastring += ",Swaps=" + str(result["Swap"] * result["VMs"]) - datastring += ",Root_GB_Seconds=" + str( - result["Root_GB"] * result["VM_Seconds"] - ) - datastring += ",Root_GBs=" + str(result["Root_GB"] * result["VMs"]) - datastring += ",Ephemeral_GB_Seconds=" + str( - result["Ephemeral_GB"] * result["VM_Seconds"] - ) - datastring += ",Ephemeral_GBs=" + str(result["Ephemeral_GB"] * result["VMs"]) - if int(accountinglib.ifnull(result["GPU_Num"], 0)) > 0: - datastring += ",GPU_Seconds=" + str( - float(result["GPU_Num"]) * float(result["VM_Seconds"]) - ) - datastring += ",GPUs=" + str( - float(result["GPU_Num"]) * float(result["VMs"]) - ) - logger.info(result) - datastring += ",COST=" + str( - float(result["GPU_Num"]) - * float(result["VM_Seconds"]) - * float(result["Per_Unit_Cost"]) - / float(3600) - ) - else: - datastring += ",COST=" + str( - float(result["VCPU"]) - * float(result["VM_Seconds"]) - * float(result["Per_Unit_Cost"]) - / float(3600) - ) - datastring += ",GPUs=" + str(0) - datastring += ",GPU_Seconds=" + str(0) - - datastring += " " + str(int(endtimestamp)) - datastring += "\n" - - r = accountinglib.send_to_influx(datastring, logger) - - logger.info(r.text) - logger.info(r) - - -if __name__ == "__main__": - main() From d134d0073f70e6888f2bdca29802721ac314e32e Mon Sep 17 00:00:00 2001 From: AnishMudaraddi Date: Mon, 24 Aug 2026 16:43:29 +0100 Subject: [PATCH 04/11] update readme --- OpenStack-accounting/README.md | 124 ++++++++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 17 deletions(-) diff --git a/OpenStack-accounting/README.md b/OpenStack-accounting/README.md index 3c2575fd..a28a0a5e 100644 --- a/OpenStack-accounting/README.md +++ b/OpenStack-accounting/README.md @@ -1,28 +1,118 @@ -# TheCount - +# Thecount - fetch, calculate and store cloud accounting data ## Prerequisites -The following packages are required: - - python3-requests - - python36-sqlalchemy - - python36-PyMySQL +A VM/system/device with docker installed +1. must have access to an Openstack DB - with an accounting user setup - to fetch relevant openstack data. +User with read access to the relevant databases for each OpenStack component you are accounting for - right now: + - cinder + - manila + - nova + - glance + +2. must have access to an influxdb TSDB - for storing parsed accounting data and setup a user with write permissions + +## Setting up SQL Procedures + +under `sql` folder, several `.sql` files exist that set out SQL procedure code that can be saved onto the database +and can be executed whenever needed to collect accounting data + +to apply login to mysql: +`mysql -u admin -p mypass` +run: +`USE cinder` +`SOURCE /sql/cinder_get_accounting_data.sql;` + +make sure you set the correct db before applying the sql file -A database user with read access to the relevant databases for each OpenStack component you are accounting for +Then you need to set permissions for accounting user +`mysql -u admin -p ` +create user - repeat for each monitoring node and where `accounting-db` is the user +`CREATE USER 'accounting-db'@'host.example.com' IDENTIFIED BY 'StrongPasswordHere!';` + +set permissions for each db - repeat for each monitoring node + +``` +GRANT SELECT, EXECUTE ON `keystone`.* TO `accounting-db`@`host.example.com`; +GRANT SELECT, EXECUTE ON `nova`.* TO `accounting-db`@`host.example.com`; +GRANT SELECT, EXECUTE ON `glance`.* TO `accounting-db`@`host.example.com`; +GRANT SELECT, EXECUTE ON `cinder`.* TO `accounting-db`@`host.example.com`; +GRANT SELECT, EXECUTE ON `manila`.* TO `accounting-db`@`host.example.com`; +GRANT SELECT, EXECUTE ON `nova_api`.* TO `accounting-db`@`host.example.com`; +GRANT SELECT, EXECUTE ON `nova_cell0`.* TO `accounting-db`@`host.example.com`; +``` + | ## Installation -Copy the scripts into `/usr/local/sbin` as shown in this repo -Create a virtual environment - `python3 -m venv /root/envs/thecount/bin/python` -Activate the virtual environment `sudo source /etc/thecount/thecount` -Install the packages ` +We deploy this with kayobe in our kayobe config repo, but if you want to setup manually you can in two ways + +### As a container + +```commandline +docker build . -t harbor.stfc.ac.uk/stfc-cloud/thecount:v1.0.0 -t harbor.stfc.ac.uk/stfc-cloud/thecount:latest +docker run \ + -v thecount.conf:/etc/thecount/thecount.conf \ + -v var/log/thecount/:/var/log/thecount/ \ + harbor.stfc.ac.uk/stfc-cloud/thecount:latest --help +``` + +on kayobe to get previous data +``` +docker run \ + --rm \ + -v /opt/kayobe/accounting-service/:/etc/thecount/ \ + --network host harbor.stfc.ac.uk/stfc-cloud/thecount:v1.0.0 \ + --config-path /etc/thecount/thecount.conf \ + --start-time --end-time --all +``` + + +run `--help` for args you can pass in and how they work +see `./thecount.conf.example` for how to create the config file + +### As a package + +you can install it as a package too + +```commandline +python -m venv thecount +source thecount/bin/activate +pip install . +``` + +then run it as a module + +``` +python3 -m thecount --help + +# run on older pre-defined start and end time (midnight to midnight) +python -m thecount --config-path /etc/thecount/thecount.conf \ + --start-time 2026-08-27 \ + --end-time 2026-08-28 \ + --interval 1440 \ + --all + +# run using bash date to generate ISO timestamp +python -m thecount --config-path /etc/thecount/thecount.conf \ + --start-time $(date +"%Y-%m-%dT%H:%M:%S" -d "yesterday") \ + --end-time $(date +"%Y-%m-%dT%H:%M:%S") \ + --jobs cinder \ + --jobs glance \ + --interval 360 \ + --dry-run \ + --all + +# run continuously starting from older start time (midnight) +python -m thecount --config-path /etc/thecount/thecount.conf --start-time 2026-08-27 --interval 1440 --all +# run continuously from current time +python -m thecount --config-path /etc/thecount/thecount.conf --interval 1440 --all -Create a config file with a database connection string in the format shown in `/etc/thecount/thecount.conf.example` in `/etc/thecount/thecount.conf` -Create the stored procedures in the `sql` directory appropriate db. +# run only cinder job +python -m thecount --config-path /etc/thecount/thecount.conf --interval 1440 --jobs cinder -## Use +# run cinder and manila job +python -m thecount --config-path /etc/thecount/thecount.conf --interval 1440 --jobs cinder --jobs manila -`now-accounting.sh` generates accounting for the last 24 hours - recommend setting up a cron to run this at midnight -`past-accounting.sh` takes a start date and an end date in the format "%Y-%m-%d %H:%M" to generate accounting for past usage where possible -`*-extract_accounting.py` takes a start date and an end date in the format "%Y-%m-%d %H:%M" to generate accounting for that component +``` From c5beba4afd4768a05cc3278843d0eb306c074d60 Mon Sep 17 00:00:00 2001 From: anish-mudaraddi Date: Wed, 2 Sep 2026 11:58:25 +0100 Subject: [PATCH 05/11] wip - change to using influx python client library --- OpenStack-accounting/pyproject.toml | 1 + OpenStack-accounting/thecount/sink.py | 82 ++++++++++++++++----------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/OpenStack-accounting/pyproject.toml b/OpenStack-accounting/pyproject.toml index 9fc795c9..6a70497b 100644 --- a/OpenStack-accounting/pyproject.toml +++ b/OpenStack-accounting/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "requests", "sqlalchemy", "pymysql", + "influxdb" ] [project.urls] diff --git a/OpenStack-accounting/thecount/sink.py b/OpenStack-accounting/thecount/sink.py index 4ba5e81a..43cceb2e 100644 --- a/OpenStack-accounting/thecount/sink.py +++ b/OpenStack-accounting/thecount/sink.py @@ -1,20 +1,22 @@ import logging from configparser import ConfigParser - -import requests from typing import Optional - + +from influxdb import InfluxDBClient +from influxdb.exceptions import InfluxDBClientError, InfluxDBServerError + logger = logging.getLogger(__name__) - -TIMEOUT = (5, 30) # (connect, read) - - + +TIMEOUT = (5, 30) # (connect, read) in seconds +DEFAULT_PORT = 8086 + + class Sink: def __init__(self, config_fp: str): """ constructor method """ - self._session: Optional[requests.Session] = None + self._client: Optional[InfluxDBClient] = None self._load_config(config_fp) - + def _load_config(self, config_fp: str) -> None: """ helper function to load in thecount config file and extract sink-related config info @@ -23,45 +25,59 @@ def _load_config(self, config_fp: str) -> None: parser = ConfigParser(interpolation=None) if not parser.read(config_fp, encoding="utf-8"): raise FileNotFoundError(f"config not found: {config_fp}") + host = parser.get("sink", "host") - database = parser.get("sink", "database") + self._host, _, port = host.partition(":") + self._port = int(port) if port else DEFAULT_PORT + self._database = parser.get("sink", "database") self.instance = parser.get("sink", "instance") - self.url = f"http://{host}/write?db={database}&precision=s" - self._auth = (parser.get("sink", "username"), parser.get("sink", "password")) - + self._username = parser.get("sink", "username") + self._password = parser.get("sink", "password") + def __enter__(self) -> "Sink": """ - Sink is managed via context manager - when context is opened, create and maintain a requests session + Sink is managed via context manager - when context is opened, create and maintain an + influxdb client (which holds an internal requests session) :return: self """ - self._session = requests.Session() - self._session.auth = self._auth - logger.debug("sink session opened for %s", self.url) + self._client = InfluxDBClient( + host=self._host, + port=self._port, + username=self._username, + password=self._password, + database=self._database, + timeout=TIMEOUT, + ) + logger.debug( + "sink client opened for %s:%d/%s", self._host, self._port, self._database + ) return self - + def __exit__(self, *exc) -> bool: """ - Sink is managed via context manager - when context is closed, close the internal requests session + Sink is managed via context manager - when context is closed, close the influxdb client """ - if self._session is not None: - self._session.close() - self._session = None + if self._client is not None: + self._client.close() + self._client = None return False - + def write(self, data_string: str) -> None: """ write data to sink influxdb - :param data_string: string - influx compatible data to pass via POST request to influxdb + :param data_string: string - influx line protocol data, one measurement per line """ - if self._session is None: + if self._client is None: raise RuntimeError("Sink.write() called outside a `with` block") - if not data_string.strip(): + + lines = [line for line in data_string.splitlines() if line.strip()] + if not lines: logger.info("nothing to write") return - - response = self._session.post(self.url, data=data_string, timeout=TIMEOUT) - if not response.ok: - raise RuntimeError( - f"influx write failed {response.status_code}: {response.text[:500]}" - ) - logger.info("wrote %d lines", len(data_string.splitlines())) \ No newline at end of file + + try: + self._client.write_points(lines, time_precision="s", protocol="line") + except (InfluxDBClientError, InfluxDBServerError) as exc: + raise RuntimeError(f"influx write failed: {exc}") from exc + + logger.info("wrote %d lines", len(lines)) From f3bb272131659e04debb93c7c7b0e1e14b9a8024 Mon Sep 17 00:00:00 2001 From: AnishMudaraddi Date: Wed, 2 Sep 2026 12:52:13 +0100 Subject: [PATCH 06/11] code review changes move jobs into subdirectory - parsers, rename to match use python influxdb client library instead of requests - required us to setup https connection for influxdb remove --interval in favor of hardcoding - for simplicity simplify parsing --start-time and --end-time to accept only yyyy-mm-dd not iso timestamps - and default to starting at midnight current day update readme to match changes ensure tests pass, and linting fixes --- OpenStack-accounting/README.md | 26 +- OpenStack-accounting/pyproject.toml | 19 +- OpenStack-accounting/tests/conftest.py | 9 +- OpenStack-accounting/tests/test_cli.py | 121 ++++------ OpenStack-accounting/tests/test_jobs.py | 41 ---- OpenStack-accounting/tests/test_main.py | 71 +++--- OpenStack-accounting/tests/test_parsers.py | 71 ++++++ OpenStack-accounting/tests/test_sink.py | 79 +++--- OpenStack-accounting/tests/test_source.py | 39 +-- OpenStack-accounting/thecount/__init__.py | 4 +- OpenStack-accounting/thecount/__main__.py | 25 +- OpenStack-accounting/thecount/cli.py | 134 +++++----- OpenStack-accounting/thecount/jobs.py | 228 ------------------ .../thecount/parsers/__init__.py | 0 .../thecount/parsers/base_parser.py | 61 +++++ .../thecount/parsers/cinder_parser.py | 36 +++ .../thecount/parsers/glance_parser.py | 37 +++ .../thecount/parsers/manila_parser.py | 37 +++ .../thecount/parsers/nova_parser.py | 69 ++++++ OpenStack-accounting/thecount/sink.py | 32 +-- OpenStack-accounting/thecount/source.py | 4 +- OpenStack-accounting/thecount/structs.py | 6 +- OpenStack-accounting/uv.lock | 143 +++++++++++ 23 files changed, 767 insertions(+), 525 deletions(-) delete mode 100644 OpenStack-accounting/tests/test_jobs.py create mode 100644 OpenStack-accounting/tests/test_parsers.py delete mode 100644 OpenStack-accounting/thecount/jobs.py create mode 100644 OpenStack-accounting/thecount/parsers/__init__.py create mode 100644 OpenStack-accounting/thecount/parsers/base_parser.py create mode 100644 OpenStack-accounting/thecount/parsers/cinder_parser.py create mode 100644 OpenStack-accounting/thecount/parsers/glance_parser.py create mode 100644 OpenStack-accounting/thecount/parsers/manila_parser.py create mode 100644 OpenStack-accounting/thecount/parsers/nova_parser.py diff --git a/OpenStack-accounting/README.md b/OpenStack-accounting/README.md index a28a0a5e..33e13f00 100644 --- a/OpenStack-accounting/README.md +++ b/OpenStack-accounting/README.md @@ -1,5 +1,8 @@ # Thecount - fetch, calculate and store cloud accounting data +This python package runs fetches various accounting data from our openstack db, parses it and stores it in influxdb +- interval is hardcoded to run every day. + ## Prerequisites A VM/system/device with docker installed @@ -64,7 +67,7 @@ docker run \ -v /opt/kayobe/accounting-service/:/etc/thecount/ \ --network host harbor.stfc.ac.uk/stfc-cloud/thecount:v1.0.0 \ --config-path /etc/thecount/thecount.conf \ - --start-time --end-time --all + --start-time --end-time ``` @@ -91,28 +94,25 @@ python -m thecount --config-path /etc/thecount/thecount.conf \ --start-time 2026-08-27 \ --end-time 2026-08-28 \ --interval 1440 \ - --all -# run using bash date to generate ISO timestamp +# run using bash date to generate timestamp python -m thecount --config-path /etc/thecount/thecount.conf \ - --start-time $(date +"%Y-%m-%dT%H:%M:%S" -d "yesterday") \ - --end-time $(date +"%Y-%m-%dT%H:%M:%S") \ + --start-time $(date +"%Y-%m-%d" -d "yesterday") \ + --end-time $(date +"%Y-%m-%d") \ --jobs cinder \ --jobs glance \ - --interval 360 \ - --dry-run \ - --all + --dry-run # run continuously starting from older start time (midnight) -python -m thecount --config-path /etc/thecount/thecount.conf --start-time 2026-08-27 --interval 1440 --all +python -m thecount --config-path /etc/thecount/thecount.conf --start-time 2026-08-27 -# run continuously from current time -python -m thecount --config-path /etc/thecount/thecount.conf --interval 1440 --all +# run continuously from current time - interval is every day +python -m thecount --config-path /etc/thecount/thecount.conf # run only cinder job -python -m thecount --config-path /etc/thecount/thecount.conf --interval 1440 --jobs cinder +python -m thecount --config-path /etc/thecount/thecount.conf --jobs cinder # run cinder and manila job -python -m thecount --config-path /etc/thecount/thecount.conf --interval 1440 --jobs cinder --jobs manila +python -m thecount --config-path /etc/thecount/thecount.conf --jobs cinder --jobs manila ``` diff --git a/OpenStack-accounting/pyproject.toml b/OpenStack-accounting/pyproject.toml index 6a70497b..4499e137 100644 --- a/OpenStack-accounting/pyproject.toml +++ b/OpenStack-accounting/pyproject.toml @@ -52,7 +52,18 @@ include = '\.pyi?$' [tool.pylint.format] max-line-length=118 disable=[ - "C0114", # Missing module string - we don't need module strings for the small repo - "W1401", # Influxdb required backslashes - "R0801" # Duplicate code due to test case -] + "missing-module-docstring", + "missing-class-docstring", + "raw-checker-failed", + "bad-inline-option", + "locally-disabled", + "file-ignored", + "suppressed-message", + "useless-suppression", + "deprecated-pragma", + "use-symbolic-message-instead", + "line-too-long", + "too-many-locals", + "fixme", + "R0801", +] \ No newline at end of file diff --git a/OpenStack-accounting/tests/conftest.py b/OpenStack-accounting/tests/conftest.py index a1245ba3..23a4d28b 100644 --- a/OpenStack-accounting/tests/conftest.py +++ b/OpenStack-accounting/tests/conftest.py @@ -46,15 +46,20 @@ def load_expected_data(path: Path) -> str: return "\n".join(lines) - @pytest.fixture def mock_source(): + """ + mock source object + """ source = MagicMock() return source @pytest.fixture def mock_sink(): + """ + mock sink object + """ sink = MagicMock() sink.instance = INSTANCE - return sink \ No newline at end of file + return sink diff --git a/OpenStack-accounting/tests/test_cli.py b/OpenStack-accounting/tests/test_cli.py index e3ffbb0e..6d4880ca 100644 --- a/OpenStack-accounting/tests/test_cli.py +++ b/OpenStack-accounting/tests/test_cli.py @@ -1,6 +1,6 @@ import argparse from unittest.mock import patch -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import pytest from conftest import ROOT_TEST_DATA_FP @@ -14,22 +14,22 @@ def make_args(**overrides) -> argparse.Namespace: """ # set config-path env variable - defaults = dict( - jobs=None, - all=False, - start_time=None, - end_time=None, - interval="3600", - dry_run=False, - config_path=ROOT_TEST_DATA_FP / "example.conf", - ) + defaults = { + "jobs": None, + "all": False, + "start_time": None, + "end_time": None, + "interval": "3600", + "dry_run": False, + "config_path": ROOT_TEST_DATA_FP / "example.conf", + } unknown = set(overrides) - set(defaults) if unknown: raise AttributeError(f"no such arg: {', '.join(sorted(unknown))}") return argparse.Namespace(**{**defaults, **overrides}) -@pytest.fixture(autouse=True) +@pytest.fixture(name="no_conn_setup", autouse=True) def no_connections(): """Sink/Source are built inside build_config and would open sockets.""" with patch("thecount.cli.Sink") as sink, patch("thecount.cli.Source") as source: @@ -37,19 +37,18 @@ def no_connections(): def test_setup_parser_defaults(): - """ test that defaults are set when no args passed """ + """test that defaults are set when no args passed""" parser = setup_parser() ns = parser.parse_args([]) assert ns.job is None assert ns.all is False assert ns.start_time is None assert ns.end_time is None - assert ns.interval == '1440' assert ns.dry_run is False def test_setup_parser_job_flag(): - """ tests that --job works correctly """ + """tests that --job works correctly""" parser = setup_parser() ns = parser.parse_args(["--job", "cinder", "--job", "manila"]) @@ -64,47 +63,41 @@ def test_setup_parser_job_flag(): def test_setup_parser_all_flag(): - """ test all flag is set to true when --all provided """ + """test all flag is set to true when --all provided""" parser = setup_parser() assert parser.parse_args(["--all"]).all is True -def test_setup_parser_job_and_all_are_mutually_exclusive(): - """ test that --job and --all cannot be used together """ - parser = setup_parser() - with pytest.raises(SystemExit) as exc: - parser.parse_args(["--job", "volumes", "--all"]) - assert exc.value.code == 2 - - def test_setup_parser_unknown_argument_exits(): - """ tests that unknown arguments raise SystemExit """ + """tests that unknown arguments raise SystemExit""" parser = setup_parser() with pytest.raises(SystemExit) as exc: parser.parse_args(["--unknown"]) assert exc.value.code == 2 -def test_build_config_interval_below_minimum_rejected(): - """ tests build_config rejects a lower interval""" - with pytest.raises(ValueError, match="< 15"): - build_config(make_args(interval="5", all=True)) +def test_defaults_all_when_neither_job_nor_all_given(): + """test that when --job nor --all are given, we default to --all""" + cfg = build_config(make_args()) + job_cls = set(type(job) for job in cfg.jobs) + assert job_cls == set(ALL_JOBS.values()) -def test_all_selects_every_job(no_connections): - """ tests that passing all=True arg creates an object for every available job class """ +def test_all_selects_every_job(): + """tests that passing all=True arg creates an object for every available job class""" cfg = build_config(make_args(all=True)) job_cls = set(type(job) for job in cfg.jobs) assert job_cls == set(ALL_JOBS.values()) def test_single_job(): - """ tests that single job object is created for valid job identifier """ + """tests that single job object is created for valid job identifier""" cfg = build_config(make_args(jobs=["cinder"])) - assert type(cfg.jobs[0]) is ALL_JOBS["cinder"] + assert isinstance(cfg.jobs[0], ALL_JOBS["cinder"]) def test_job_values_split(): + """tests that providing multiple jobs works properly """ cfg = build_config(make_args(jobs=["cinder", "nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -112,6 +105,7 @@ def test_job_values_split(): def test_job_values_no_whitespace_tolerated(): + """ test that --job comma-spaced values with no whitespace is parsed correctly """ cfg = build_config(make_args(jobs=["cinder,nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -119,6 +113,7 @@ def test_job_values_no_whitespace_tolerated(): def test_job_values_whitespace_tolerated(): + """ test that --job comma-spaced values with whitespace is parsed properly """ cfg = build_config(make_args(jobs=["cinder,nova", "glance"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"], ALL_JOBS["glance"]] @@ -126,6 +121,7 @@ def test_job_values_whitespace_tolerated(): def test_job_values_empty_vals_ignored(): + """ test that multiple commas are handled correctly """ cfg = build_config(make_args(jobs=["cinder,,nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -133,6 +129,7 @@ def test_job_values_empty_vals_ignored(): def test_duplicates_run_once(): + """ test that duplicate jobs are removed and only set to run once""" cfg = build_config(make_args(jobs=["cinder", "nova,cinder"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -140,72 +137,56 @@ def test_duplicates_run_once(): def test_unknown_job_named_in_error(): + """ test that unknown job names raise an error """ with pytest.raises(ValueError, match="foo"): build_config(make_args(jobs=["cinder,foo"])) -@pytest.mark.parametrize("text", ["14", "1", "0"]) -def test_interval_below_minimum_rejected(text): - """ test that interval parsing raises an error if time interval less than 15m """ - with pytest.raises(ValueError, match="< 15"): - build_config(make_args(all=True, interval=text)) - - -@pytest.mark.parametrize("text", ["foo", "5m", "d", ""]) -def test_malformed_interval_rejected(text): - """ test that malformed interval fails.""" - with pytest.raises(ValueError, match="malformed"): - build_config(make_args(all=True, interval=text)) - - -def test_start_defaults_to_now(): - """ test that start time sets now() time properly """ - before = datetime.now(timezone.utc) +def test_start_defaults_to_current_date_midnight(): + """test that start time sets now() time properly""" + curr = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) cfg = build_config(make_args(all=True)) - assert before <= cfg.start_time <= datetime.now(timezone.utc) + assert cfg.start_time == curr def test_end_defaults_to_none(): - """ tests that end time defaults to None""" + """tests that end time defaults to None""" assert build_config(make_args(all=True)).end_time is None -def test_explicit_times_parsed(): - """ tests that ISO 8601 formatting strings are accepted """ +def test_yyyymmdd_times_parse_correctly(): + """tests that ISO 8601 formatting strings are accepted""" cfg = build_config( - make_args(all=True, start_time="2026-01-01", end_time="2026-01-02T09:30") + make_args(all=True, start_time="2026-01-01", end_time="2026-01-02") ) assert cfg.start_time == datetime(2026, 1, 1, tzinfo=timezone.utc) - assert cfg.end_time == datetime(2026, 1, 2, 9, 30, tzinfo=timezone.utc) - - -def test_naive_input_treated_as_utc(): - """tests that strings are parsed as UTC timezone by default.""" - cfg = build_config(make_args(all=True, start_time="2026-01-01T00:00")) - assert cfg.start_time.tzinfo is not None - assert cfg.start_time.utcoffset() == timedelta(0) + assert cfg.end_time == datetime(2026, 1, 2, tzinfo=timezone.utc) def test_start_after_end_rejected(): - """ tests to check end time is always after start time - fails if not """ + """tests to check end time is always after start time - fails if not""" with pytest.raises(ValueError, match="before"): - build_config(make_args(all=True, start_time="2026-02-01", end_time="2026-01-01")) + build_config( + make_args(all=True, start_time="2026-02-01", end_time="2026-01-01") + ) def test_start_equal_to_end_rejected(): - """ test to check end_time and start_time are different """ + """test to check end_time and start_time are different""" with pytest.raises(ValueError, match="before"): - build_config(make_args(all=True, start_time="2026-01-01", end_time="2026-01-01")) + build_config( + make_args(all=True, start_time="2026-01-01", end_time="2026-01-01") + ) def test_dry_run_passed_through(): - """ test to check dry_run is passed through to output config """ + """test to check dry_run is passed through to output config""" assert build_config(make_args(all=True, dry_run=True)).dry_run is True -def test_sink_and_source_get_config_path(no_connections): - """ tests that sink and source are created with correct config path from inputs""" - sink, source = no_connections +def test_sink_and_source_get_config_path(no_conn_setup): + """tests that sink and source are created with correct config path from inputs""" + sink, source = no_conn_setup build_config(make_args(all=True, config_path="/etc/thecount.ini")) sink.assert_called_once_with("/etc/thecount.ini") source.assert_called_once_with("/etc/thecount.ini") diff --git a/OpenStack-accounting/tests/test_jobs.py b/OpenStack-accounting/tests/test_jobs.py deleted file mode 100644 index 7a404aef..00000000 --- a/OpenStack-accounting/tests/test_jobs.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest - -from conftest import ROOT_TEST_DATA_FP, END_TIME, START_TIME, load_example_data, load_expected_data -from thecount.jobs import CinderAccounting, NovaAccounting, ManilaAccounting, GlanceAccounting - -JOBS = { - "cinder": CinderAccounting, - "nova": NovaAccounting, - "manila": ManilaAccounting, - "glance": GlanceAccounting, -} - -@pytest.mark.parametrize("dirname", JOBS) -def test_job_output(dirname, mock_source, mock_sink): - job_cls = JOBS[dirname] - fixture_dir = ROOT_TEST_DATA_FP / dirname - rows = load_example_data(fixture_dir / "input.txt") - expected = load_expected_data(fixture_dir / "exp.txt") - - mock_source.fetch.return_value = rows - job = job_cls() - job.run(sink=mock_sink, source=mock_source, start_time=START_TIME, end_time=END_TIME) - - mock_source.fetch.assert_called_once_with( - database=job_cls.db_name, - start_time=START_TIME, - end_time=END_TIME, - ) - mock_sink.write.assert_called_once() - actual = mock_sink.write.call_args.args[0] - - assert isinstance(actual, str), f"sink.write got {type(actual).__name__}" - assert actual.splitlines() == expected.splitlines() - - -@pytest.mark.parametrize("dirname", JOBS) -def test_dry_run_does_not_write(dirname, mock_source, mock_sink): - """ test that when dry run is true, sink.write is not called """ - mock_source.fetch.return_value = load_example_data(ROOT_TEST_DATA_FP / dirname / "input.txt") - JOBS[dirname]().run(sink=mock_sink, source=mock_source, start_time=START_TIME, end_time=END_TIME, dry_run=True) - mock_sink.write.assert_not_called() diff --git a/OpenStack-accounting/tests/test_main.py b/OpenStack-accounting/tests/test_main.py index 816df224..792673ab 100644 --- a/OpenStack-accounting/tests/test_main.py +++ b/OpenStack-accounting/tests/test_main.py @@ -4,16 +4,18 @@ import pytest -from thecount.jobs import Job -from thecount.main import run_jobs +from thecount.parsers.base_parser import BaseParser +from thecount.__main__ import run_jobs from thecount.source import Source from thecount.sink import Sink from thecount.structs import RunDetails + class StopLoop(Exception): """Sentinel to break out of the unbounded `while True`.""" -class TestJob(Job): + +class TestJob(BaseParser): """Base for fake jobs; subclasses get their own `calls` list.""" def __init__(self, limit: int | None = None, name: str = "jobA"): @@ -23,16 +25,21 @@ def __init__(self, limit: int | None = None, name: str = "jobA"): self.calls = [] def run( - self, - source: Source, - sink: Sink, - start_time: datetime, - end_time: datetime, - dry_run=False + self, + source: Source, + sink: Sink, + start_time: datetime, + end_time: datetime, + dry_run=False, ): self.calls.append( - dict(start=start_time, end=end_time, dry_run=dry_run, - sink=sink, source=source) + { + "start": start_time, + "end": end_time, + "dry_run": dry_run, + "sink": sink, + "source": source, + } ) # a way to stop an endless loop if self.limit is not None and len(self.calls) >= self.limit: @@ -44,7 +51,7 @@ def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance): def make_job(name: str, limit: int | None = None) -> TestJob: - """ helper to create a TestJob class and set the name/looping limit """ + """helper to create a TestJob class and set the name/looping limit""" test_job = TestJob() test_job.name = name test_job.limit = limit @@ -52,13 +59,13 @@ def make_job(name: str, limit: int | None = None) -> TestJob: def setup_run_details( - jobs: List[Job], - interval: timedelta, - start_time: datetime, - end_time: datetime | None, - dry_run: bool = False + jobs: List[BaseParser], + interval: timedelta, + start_time: datetime, + end_time: datetime | None, + dry_run: bool = False, ) -> RunDetails: - """ helper to construct a RunDetails dataclass for tests """ + """helper to construct a RunDetails dataclass for tests""" return RunDetails( source=MagicMock(), sink=MagicMock(), @@ -69,15 +76,16 @@ def setup_run_details( end_time=end_time, ) -@pytest.fixture(autouse=True) -def stub_sleep(): - """ stubs out sleep for testing """ + +@pytest.fixture(name="stub_sleep", autouse=True) +def stub_sleep_fn(): + """stubs out sleep for testing""" with patch("thecount.__main__.time.sleep") as sleep: yield sleep def test_run_jobs_one_interval(): - """ tests run loop runs once when one interval between start and end time """ + """tests run loop runs once when one interval between start and end time""" job = make_job("foo") interval = timedelta(hours=1) start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) @@ -87,6 +95,7 @@ def test_run_jobs_one_interval(): assert job.calls[0]["start"] == start_time assert job.calls[0]["end"] == end_time + def test_run_jobs_multiple_contiguous_intervals(): """ tests run loop runs more than once when start time and end time can be @@ -106,8 +115,9 @@ def test_run_jobs_multiple_contiguous_intervals(): (start_time + 2 * interval, start_time + 3 * interval), ] + def test_run_jobs_partial_interval_ignored(): - """ tests that trailing time window too small for a full interval before end-time is ignored """ + """tests that trailing time window too small for a full interval before end-time is ignored""" job = make_job("foo") interval = timedelta(hours=1) start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) @@ -120,7 +130,7 @@ def test_run_jobs_partial_interval_ignored(): def test_run_jobs_ignores_large_interval(): - """ tests if the interval is too big between start and end time, should return nothing """ + """tests if the interval is too big between start and end time, should return nothing""" job = make_job("foo") interval = timedelta(hours=1) @@ -130,8 +140,9 @@ def test_run_jobs_ignores_large_interval(): run_jobs(setup_run_details([job], interval, start_time, end_time)) assert len(job.calls) == 0 + def test_run_jobs_multiple_jobs_one_interval(): - """ tests that multiple jobs run per interval """ + """tests that multiple jobs run per interval""" a, b = make_job("a"), make_job("b") interval = timedelta(hours=1) start_time = datetime(2026, 1, 1, 0, tzinfo=timezone.utc) @@ -141,11 +152,12 @@ def test_run_jobs_multiple_jobs_one_interval(): assert len(a.calls) == 2 and len(b.calls) == 2 assert [c["start"] for c in a.calls] == [c["start"] for c in b.calls] + def test_run_job_sleeps_when_interval_end_in_future(stub_sleep): """ tests that run_jobs() sleeps when interval end is calculated as in the future and then calls jobs.run() after sleeping - """ + """ job = make_job("foo") interval = timedelta(hours=1) start_time = datetime.now(timezone.utc) @@ -164,14 +176,15 @@ def test_run_job_sleeps_when_interval_end_in_future(stub_sleep): # ensure sleep call was run before run() call assert job.calls[0] == "sleep" + def test_runs_indefinitely_without_end_time(): - """ tests that continuous looping works """ + """tests that continuous looping works""" - job = make_job("foo", limit=5) # break continuous loop at 5 loops + job = make_job("foo", limit=5) # break continuous loop at 5 loops interval = timedelta(hours=1) start_time = datetime.now(timezone.utc) with pytest.raises(StopLoop): run_jobs(setup_run_details([job], interval, start_time, None)) assert len(job.calls) == 5 - assert job.calls[-1]["end"] == start_time + 5 * interval \ No newline at end of file + assert job.calls[-1]["end"] == start_time + 5 * interval diff --git a/OpenStack-accounting/tests/test_parsers.py b/OpenStack-accounting/tests/test_parsers.py new file mode 100644 index 00000000..41defe47 --- /dev/null +++ b/OpenStack-accounting/tests/test_parsers.py @@ -0,0 +1,71 @@ +import pytest + +from conftest import ( + ROOT_TEST_DATA_FP, + END_TIME, + START_TIME, + load_example_data, + load_expected_data, +) +from thecount.parsers.glance_parser import GlanceParser +from thecount.parsers.cinder_parser import CinderParser +from thecount.parsers.manila_parser import ManilaParser +from thecount.parsers.nova_parser import NovaParser + +JOBS = { + "cinder": CinderParser, + "nova": NovaParser, + "manila": ManilaParser, + "glance": GlanceParser, +} + + +@pytest.mark.parametrize("dirname", JOBS) +def test_job_output(dirname, mock_source, mock_sink): + """ + for each set of example data in test_date, tests that job.run() is correctly runs + example data contains: + - input.txt -> loaded into dictionary that mocks output from source.fetch() + - exp.txt -> contains comma-spaced string which is written to influxdb using sink.write() + + mocks source.fetch() returning contexts of input.txt, compares parsed output from _transform to exp.txt + and ensures sink.write() is run correctly. + + """ + job_cls = JOBS[dirname] + fixture_dir = ROOT_TEST_DATA_FP / dirname + rows = load_example_data(fixture_dir / "input.txt") + expected = load_expected_data(fixture_dir / "exp.txt") + + mock_source.fetch.return_value = rows + job = job_cls() + job.run( + sink=mock_sink, source=mock_source, start_time=START_TIME, end_time=END_TIME + ) + + mock_source.fetch.assert_called_once_with( + database=job_cls.db_name, + start_time=START_TIME, + end_time=END_TIME, + ) + mock_sink.write.assert_called_once() + actual = mock_sink.write.call_args.args[0] + + assert isinstance(actual, str), f"sink.write got {type(actual).__name__}" + assert actual.splitlines() == expected.splitlines() + + +@pytest.mark.parametrize("dirname", JOBS) +def test_dry_run_does_not_write(dirname, mock_source, mock_sink): + """test that when dry run is true, sink.write is not called""" + mock_source.fetch.return_value = load_example_data( + ROOT_TEST_DATA_FP / dirname / "input.txt" + ) + JOBS[dirname]().run( + sink=mock_sink, + source=mock_source, + start_time=START_TIME, + end_time=END_TIME, + dry_run=True, + ) + mock_sink.write.assert_not_called() diff --git a/OpenStack-accounting/tests/test_sink.py b/OpenStack-accounting/tests/test_sink.py index f8853194..d221122e 100644 --- a/OpenStack-accounting/tests/test_sink.py +++ b/OpenStack-accounting/tests/test_sink.py @@ -2,13 +2,17 @@ import configparser import pytest +from influxdb.exceptions import InfluxDBClientError from thecount.sink import Sink, TIMEOUT DATASTRING = "cpu,host=a value=1 1700000000\ncpu,host=b value=2 1700000000" +PARSED_DATASTRING = ["cpu,host=a value=1 1700000000", "cpu,host=b value=2 1700000000"] -@pytest.fixture() -def valid_config_file(tmp_path): + +@pytest.fixture(name="valid_config_file") +def valid_config_file_fn(tmp_path): + """ creates a mock valid thecount.conf config file """ path = tmp_path / "t3.conf" path.write_text( "[sink]\n" @@ -21,14 +25,15 @@ def valid_config_file(tmp_path): ) return path -def test_reads_fail_when_file_not_found(tmp_path): - """ tests that file not found error raised when provided a filepath that doesn't exist """ + +def test_reads_fail_when_file_not_found(): + """tests that file not found error raised when provided a filepath that doesn't exist""" with pytest.raises(FileNotFoundError, match="config not found"): Sink(str("na.conf")) def test_missing_section_raises(tmp_path): - """ tests that error raised when provided a config file without relevant "sink" section """ + """tests that error raised when provided a config file without relevant "sink" section""" path = tmp_path / "t1.conf" path.write_text("[foo]\nbar = x\n") with pytest.raises(configparser.NoSectionError): @@ -36,59 +41,63 @@ def test_missing_section_raises(tmp_path): def test_read_missing_key_raises(tmp_path): - """ tests that error is raised when provided a config file with "sink" section but relevant keys missing """ + """tests that error is raised when provided a config file with "sink" section but relevant keys missing""" path = tmp_path / "t2.conf" path.write_text("[sink]\nbar = x\n") with pytest.raises(configparser.NoOptionError): Sink(str(path)) -@patch('thecount.sink.requests.Session') -def test_write_success(mock_request_session, valid_config_file): - """ test write sets up proper session and posts correct data """ - - mock_session = mock_request_session.return_value - mock_session.post.return_value.ok = True - +@patch("thecount.sink.InfluxDBClient") +def test_write_success(mock_influxdb_client, valid_config_file): + """test write sets up proper influxdb client and writes correct data""" + mock_client = mock_influxdb_client.return_value with Sink(str(valid_config_file)) as sink: sink.write(DATASTRING) - mock_request_session.assert_called_once() - mock_session.post.assert_called_once_with( - "http://influx.example.com/write?db=accounting&precision=s", - data=DATASTRING, - timeout=TIMEOUT + mock_client.write_points.assert_called_once_with( + PARSED_DATASTRING, time_precision="s", protocol="line" + ) + + mock_influxdb_client.assert_called_once_with( + host="influx.example.com", + port=8086, + username="user", + password="pass", + database="accounting", + ssl=True, + verify_ssl=False, + timeout=TIMEOUT, ) - assert sink.instance == "preprod" -@patch('thecount.sink.requests.Session') -def test_write_fail(mock_request_session, valid_config_file): - """ raises runtime error if session.post().ok returns response that is not True """ - mock_session = mock_request_session.return_value - mock_session.post.return_value.ok = False +@patch("thecount.sink.InfluxDBClient") +def test_write_fail(mock_influxdb_client, valid_config_file): + """raises runtime error if influxdbclient write_points raises influxdbclient error""" + mock_session = mock_influxdb_client.return_value + mock_session.write_points.side_effect = InfluxDBClientError("foo") with pytest.raises(RuntimeError): with Sink(str(valid_config_file)) as sink: sink.write(DATASTRING) - mock_request_session.assert_called_once() - mock_session.post.assert_called_once_with( - "http://influx.example.com/write?db=accounting&precision=s", - data=DATASTRING, - timeout=TIMEOUT + mock_influxdb_client.assert_called_once() + mock_session.write_points.assert_called_once_with( + PARSED_DATASTRING, + time_precision="s", + protocol="line", ) -@patch('thecount.sink.requests.Session') -def test_write_empty_datastring_does_nothing(mock_request_session, valid_config_file): - """ tests that write doesn't post anything if provided an empty datastring """ - mock_session = mock_request_session.return_value +@patch("thecount.sink.InfluxDBClient") +def test_write_empty_datastring_does_nothing(mock_influxdb_client, valid_config_file): + """tests that write doesn't post anything if provided an empty datastring""" + mock_session = mock_influxdb_client.return_value with Sink(str(valid_config_file)) as sink: sink.write("") - mock_request_session.assert_called_once() - mock_session.post.assert_not_called() \ No newline at end of file + mock_influxdb_client.assert_called_once() + mock_session.post.assert_not_called() diff --git a/OpenStack-accounting/tests/test_source.py b/OpenStack-accounting/tests/test_source.py index 08cd35a0..13406362 100644 --- a/OpenStack-accounting/tests/test_source.py +++ b/OpenStack-accounting/tests/test_source.py @@ -9,24 +9,33 @@ START = datetime(2026, 1, 1, 9, 30, tzinfo=timezone.utc) END = datetime(2026, 1, 2, 10, 45, tzinfo=timezone.utc) -@pytest.fixture -def valid_config_file(tmp_path): + +@pytest.fixture(name="valid_config_file") +def valid_config_file_fn(tmp_path): + """ mocks a valid config file thecount.conf""" path = tmp_path / "t1.conf" - path.write_text("[source]\nconnection = mysql+pymysql://dbuser:pass@exampledb:3306\n") + path.write_text( + "[source]\nconnection = mysql+pymysql://dbuser:pass@exampledb:3306\n" + ) return path + def test_reads_connection_string(valid_config_file): - assert Source(str(valid_config_file)).conn_string == "mysql+pymysql://dbuser:pass@exampledb:3306" + """ test that connection string is read correctly from config file """ + assert ( + Source(str(valid_config_file)).conn_string + == "mysql+pymysql://dbuser:pass@exampledb:3306" + ) -def test_reads_fail_when_file_not_found(tmp_path): - """ tests that file not found error raised when provided a filepath that doesn't exist """ +def test_reads_fail_when_file_not_found(): + """tests that file not found error raised when provided a filepath that doesn't exist""" with pytest.raises(FileNotFoundError, match="config not found"): Source(str("na.conf")) def test_missing_section_raises(tmp_path): - """ tests that error raised when provided a config file without relevant "source" section """ + """tests that error raised when provided a config file without relevant "source" section""" path = tmp_path / "t2.conf" path.write_text("[foo]\nbar = x\n") with pytest.raises(configparser.NoSectionError): @@ -34,7 +43,7 @@ def test_missing_section_raises(tmp_path): def test_missing_key_raises(tmp_path): - """ tests that error is raised when provided a config file with "source" section but no "connection" var """ + """tests that error is raised when provided a config file with "source" section but no "connection" var""" path = tmp_path / "t3.conf" path.write_text("[source]\nother = x\n") with pytest.raises(configparser.NoOptionError): @@ -42,19 +51,22 @@ def test_missing_key_raises(tmp_path): def test_fetch_outside_with_block_raises(valid_config_file): + """tests that error is raised when instantiating Source outside a with block""" with pytest.raises(RuntimeError): Source(str(valid_config_file)).fetch("nova", START, END) -@patch('thecount.source.Session') -@patch('thecount.source.sqlalchemy.create_engine') -def test_fetch_returns_rows_as_dicts(mock_create_engine, mock_session_cls, valid_config_file): - """ test fetch calls database create_engine correctly """ +@patch("thecount.source.Session") +@patch("thecount.source.sqlalchemy.create_engine") +def test_fetch_returns_rows_as_dicts( + mock_create_engine, mock_session_cls, valid_config_file +): + """test fetch calls database create_engine correctly""" mock_session = mock_session_cls.return_value.__enter__.return_value mock_session.execute.return_value.mappings.return_value = [ {"foo": 1, "bar": "alpha"}, - {"foo": 2, "bar": "beta"} + {"foo": 2, "bar": "beta"}, ] with Source(str(valid_config_file)) as source: @@ -76,4 +88,3 @@ def test_fetch_returns_rows_as_dicts(mock_create_engine, mock_session_cls, valid # assert results are the parsed results of calling session.execute().mappings() assert result[0] == {"foo": 1, "bar": "alpha"} assert result[1] == {"foo": 2, "bar": "beta"} - diff --git a/OpenStack-accounting/thecount/__init__.py b/OpenStack-accounting/thecount/__init__.py index 45dc213e..78a161f1 100644 --- a/OpenStack-accounting/thecount/__init__.py +++ b/OpenStack-accounting/thecount/__init__.py @@ -24,9 +24,9 @@ # Log to file if not os.path.exists(LOG_FP): - logger.warning(f"log filepath {LOG_FP} does not exist, logs aren't being stored...") + logger.warning("log filepath %s does not exist, logs aren't being stored...", LOG_FP) else: file_handler = logging.FileHandler(LOG_FP) file_handler.setLevel(log_level) file_handler.setFormatter(formatter) - logger.addHandler(file_handler) \ No newline at end of file + logger.addHandler(file_handler) diff --git a/OpenStack-accounting/thecount/__main__.py b/OpenStack-accounting/thecount/__main__.py index e7b2f32d..deb89350 100644 --- a/OpenStack-accounting/thecount/__main__.py +++ b/OpenStack-accounting/thecount/__main__.py @@ -8,7 +8,13 @@ logger = logging.getLogger(__name__) + def run_jobs(details: RunDetails) -> None: + """ + function that runs jobs continuously at regular intervals until a specified end_time param reached. + Sleeps between intervals. + :param details: RunDetails object - containing parameters on what jobs to run and for how long + """ with details.source, details.sink: step_counter = 0 current_time = details.start_time @@ -18,30 +24,31 @@ def run_jobs(details: RunDetails) -> None: # If there isn't a complete interval before end_time, stop. if details.end_time is not None and interval_end > details.end_time: - logger.info( - f" {details.end_time} reached stopping..." - ) + logger.info("%s reached. Stopping...", details.end_time) break logger.info( - f"Starting Loop: {step_counter}: interval_start_time: {current_time}, interval_end_time: {interval_end}" + "Starting Loop: %s: interval_start_time: %s, interval_end_time: %s", + step_counter, + current_time, + interval_end ) # Don't start the process until end of the current interval - so all data is available now = datetime.now(current_time.tzinfo) if interval_end > now: - logger.info(f"waiting until {interval_end.isoformat()}...") + logger.info("waiting until %s...", interval_end.isoformat()) time.sleep((interval_end - now).total_seconds()) # Run every job for this interval. for job in details.jobs: - logger.info(f"running job: {type(job)}, dry_run={details.dry_run}") + logger.info("running job: %s, dry_run=%s", type(job), details.dry_run) job.run( source=details.source, sink=details.sink, start_time=current_time, end_time=interval_end, - dry_run=details.dry_run + dry_run=details.dry_run, ) # Move to the next interval. @@ -49,6 +56,7 @@ def run_jobs(details: RunDetails) -> None: def main() -> int: + """ main function - sets up parser, parses args and calls run_jobs() """ parser = setup_parser() args = parser.parse_args() @@ -60,5 +68,6 @@ def main() -> int: run_jobs(run_details) return 0 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/OpenStack-accounting/thecount/cli.py b/OpenStack-accounting/thecount/cli.py index d6c12711..193208bc 100644 --- a/OpenStack-accounting/thecount/cli.py +++ b/OpenStack-accounting/thecount/cli.py @@ -1,6 +1,5 @@ import os import argparse -import re import logging from datetime import datetime, timedelta, timezone from typing import Optional, List @@ -8,18 +7,27 @@ from thecount.sink import Sink from thecount.source import Source from thecount.structs import RunDetails -from thecount.jobs import Job, CinderAccounting, GlanceAccounting, NovaAccounting, ManilaAccounting + +from thecount.parsers.base_parser import BaseParser +from thecount.parsers.glance_parser import GlanceParser +from thecount.parsers.cinder_parser import CinderParser +from thecount.parsers.manila_parser import ManilaParser +from thecount.parsers.nova_parser import NovaParser logger = logging.getLogger(__name__) # parse jobs -ALL_JOBS: dict[str, type[Job]] = { - "cinder": CinderAccounting, - "glance": GlanceAccounting, - "nova": NovaAccounting, - "manila": ManilaAccounting +ALL_JOBS: dict[str, type[BaseParser]] = { + "cinder": CinderParser, + "glance": GlanceParser, + "nova": NovaParser, + "manila": ManilaParser, } +# set constant interval as 1440 seconds - 1 day +INTERVAL = 1440 + + def setup_parser() -> argparse.ArgumentParser: """ setup parser rules, to read in and validate command line arguments @@ -28,46 +36,52 @@ def setup_parser() -> argparse.ArgumentParser: prog="thecount", description="Extract accounting records from openstack db and send them to the influx.", epilog=( - "Only ISO 8601 time formats are accepted (2024-01-01, 2024-01-01T09:00) " - "values without timezone are treated as UTC." + "Only yyyy-mm-dd time formats are accepted (e.g. 2024-01-01) " + "timezone is always treated as UTC." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) - - selection = parser.add_mutually_exclusive_group() - selection.add_argument( - "--job", action="append", metavar="NAME", default=None, + parser.add_argument( + "--job", + action="append", + metavar="NAME", + default=None, help="accounting job to run; repeatable, or comma-separated - see --list-jobs for full list", ) - selection.add_argument("--all", action="store_true", help="run every accounting job") + parser.add_argument( + "--all", action="store_true", default=False, help="run every accounting job" + ) # default is None as current time calculated at runtime parser.add_argument( - "--start-time", metavar="TIME", default=None, - help="(optional) start of the range ISO 8601 format( yyyy-mm-ddTHH:MM:SS ), " - "if not given, --start-time will be set as the current time", + "--start-time", + metavar="TIME", + default=None, + help="(optional) start of the range in format: yyyy-mm-dd" + "if not given, --start-time will be set as the current date", ) parser.add_argument( - "--end-time", metavar="TIME", default=None, + "--end-time", + metavar="TIME", + default=None, help="end of the range, if in the future, script will complete once that range is reached" - " if not given, will run from given --start-time and keep running forever until killed (default: None)" - " ISO 8601 format( yyyy-mm-ddTHH:MM:SS )", + " if not given, will run from given --start-time and keep running forever until killed (default: None)" + " format yyyy-mm-dd", ) parser.add_argument( - "--interval", metavar="DURATION", default="1440", - help="size of window (in minutes) for each accounting extraction period - USAGE: 1440, 60, 15 " - "value must be >=15, default is 1440 (1 day)", - ) - parser.add_argument( - "--dry-run", action="store_true", + "--dry-run", + action="store_true", help="print the records to be sent instead of sending to influxdb", ) parser.add_argument( - "--config-path", metavar="THE_COUNT_CONFIG_FILE", default=os.environ.get("$THE_COUNT_CONFIG_FILE"), + "--config-path", + metavar="THE_COUNT_CONFIG_FILE", + default=os.environ.get("THE_COUNT_CONFIG_FILE"), help="config file with source and sink credentials (default: $THE_COUNT_CONFIG_FILE)", ) return parser + def _split_jobs(values: Optional[List[str]]) -> list[str]: """ Helper function that splits comma-spaced job names into a list of distinct jobs to run @@ -87,13 +101,17 @@ def _split_jobs(values: Optional[List[str]]) -> list[str]: return list(dict.fromkeys(names)) -def _as_utc(dt: datetime) -> datetime: +def _parse_date(value: str) -> datetime: """ - Helper string to convert datetime to UTC if timezone given - dt: python datetime object - :returns: equivalent python datetime object converted to the equivalent UTC timezone time + Helper string to convert yyyy-mm-dd string into UTC datetime + value: string to represent date in format yyyy-mm-dd + :returns: equivalent python datetime object with UTC timezone info """ - return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + try: + return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError as e: + raise ValueError(f"unable to parse {value}, accepted format YYYY-MM-DD") from e + def build_config(args: argparse.Namespace) -> RunDetails: """ @@ -103,46 +121,42 @@ def build_config(args: argparse.Namespace) -> RunDetails: :returns: a RunDetails struct containing parameters on what accounting jobs to run and how to run them """ + if args.all and args.jobs: + raise ValueError("--all cannot be used with --job") + + # defaults to --all if neither given - i.e. run all jobs in list names = list(ALL_JOBS) - if not args.all: + + # if --jobs specified, parse input and validate + if args.jobs: names = _split_jobs(args.jobs) - if not names: - raise ValueError("specify --job NAME (repeatable) or --all") unknown = [name for name in names if name not in ALL_JOBS] if unknown: - raise ValueError(f"unknown job(s): {', '.join(unknown)}. Available: {ALL_JOBS}") + raise ValueError( + f"unknown job(s): {', '.join(unknown)}. Available: {ALL_JOBS}" + ) + requested_jobs = [ALL_JOBS[name]() for name in names] - # times and durations + # parse times try: - interval = timedelta(minutes=int(args.interval)) + end = _parse_date(args.end_time) if args.end_time else None except ValueError as e: - raise ValueError(f"malformed interval {args.interval}") from e - if interval < timedelta(minutes=15): - raise ValueError(f"interval {args.interval} set at < 15 which is likely to cause DB slowdowns") - - if args.end_time: - try: - end = _as_utc(datetime.fromisoformat(args.end_time)) - except ValueError: - raise ValueError( - f"unable to parse --end-time {args.end_time}, accepted format ISO 8601'" - ) - else: - end = None + raise ValueError(f"unable to parse --end-time: {e}") from e + if end is None: logger.info("--end-time not set, running continuously...") if args.start_time: try: - start = _as_utc(datetime.fromisoformat(args.start_time)) - except ValueError: - raise ValueError( - f"unable to parse --start-time {args.start_time}, accepted format ISO 8601'" - ) + start = _parse_date(args.start_time) + except ValueError as e: + raise ValueError(f"unable to parse --start-time: {e}") from e else: - start = datetime.now(timezone.utc) - logger.info(f"--start-time set to {start}") + today = datetime.now(timezone.utc).date() + start = datetime(today.year, today.month, today.day, tzinfo=timezone.utc) + logger.info("--start-time set to %s", start) + # if --end-time given, make sure it is after --start-time (or current date if not given) if end and start >= end: raise ValueError( f"--start-time ({start.isoformat()}) must be before " @@ -154,7 +168,7 @@ def build_config(args: argparse.Namespace) -> RunDetails: sink=Sink(args.config_path), source=Source(args.config_path), dry_run=args.dry_run, - interval=interval, + interval=timedelta(seconds=INTERVAL), start_time=start, end_time=end, - ) \ No newline at end of file + ) diff --git a/OpenStack-accounting/thecount/jobs.py b/OpenStack-accounting/thecount/jobs.py deleted file mode 100644 index 2bbee426..00000000 --- a/OpenStack-accounting/thecount/jobs.py +++ /dev/null @@ -1,228 +0,0 @@ -from typing import List, Dict, Any -import logging -from abc import ABC, abstractmethod -from datetime import datetime - -from thecount.sink import Sink -from thecount.source import Source - -logger = logging.getLogger(__name__) - -class Job(ABC): - """ - Abstract base class for all accounting jobs - """ - db_name = "" - - def run( - self, - source: Source, - sink: Sink, - start_time: datetime, - end_time: datetime, - dry_run=False - ) -> None: - """ - function that fetches data from the source, parses and transforms it, then passes it to the sink - source: a Source object is configured to fetch accounting data - - implements the fetch() method - sink: a Sink object is configured to write transformed/parsed accounting data somewhere - - implements the write() method - start_time: python datetime - the start time of the job - end_time: python datetime - the end time of the job - dry_run: boolean - whether to run the job in dry run mode - i.e. print to console instead of sending - it to source (useful for debugging) - """ - rows = source.fetch( - database=self.db_name, - start_time=start_time, - end_time=end_time, - ) - out = self._transform(rows, end_time, sink.instance) - print(out) if dry_run else sink.write(out) - - @staticmethod - def get_department(result: Dict[str, Any]) -> str: - """ - helper function returns an appropriate accounting department for each project from source - """ - department = result["Department"] - if "rally" in result["Project"]: - department = "STFC Cloud" - elif "default" in result["Department"].casefold(): - department = result["Project"] - return department - - @staticmethod - @abstractmethod - def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: - """ABSTRACT CLASS - to be implemented by subclasses""" - pass - - -class CinderAccounting(Job): - db_name="cinder" - - @staticmethod - def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: - """ - transform cinder data from source to get total amount of usage (GB seconds) for block-storage per project - :rows: list of dictionaries containing cinder data from source database - :end_time: python datetime - the end time of the job - used to timestamp the entry to sink - :instance: string annotation to pass to sink to state which source the data came from - """ - datastring = "" - for row in rows: - department = Job.get_department(row).replace(' ', r'\ ') - project = row['Project'].replace(' ', r'\ ') - - datastring += ( - f"Accounting" - f",instance={instance}" - f",AvailabilityZone={row['AvailabilityZone']}" - f",Project={project}" - f",Department={department}" - f",CinderType={row['CinderType']}" - f",YYYY-MM={end_time.strftime('%Y-%m')}" - - f" Volumes={row['Volumes']}" - f",Volume_Seconds={row['Volume_Seconds']}" - f",CinderGBs={row['Volume_GB'] * row['Volume_Seconds'] * row['Volumes']}" - - f" {end_time.timestamp():.0f}\n" - ) - return datastring - - -class GlanceAccounting(Job): - db_name="glance" - - @staticmethod - def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: - """ - transform glance data from source to get total image/snapshot storage usage (GB seconds) per project - :rows: list of dictionaries containing glance data from source database - :end_time: python datetime - the end time of the job - used to timestamp the entry to sink - :instance: string annotation to pass to sink to state which source the data came from - """ - datastring = "" - for row in rows: - department = Job.get_department(row).replace(' ', r'\ ') - project = row['Project'].replace(' ', r'\ ') - - datastring += ( - f"Accounting" - f",instance={instance}" - f",Project={project}" - f",Department={department}" - f",StorageBackend={row['StorageBackend']}" - f",GlanceType={row['GlanceType']}" - f",YYYY-MM={end_time.strftime('%Y-%m')}" - - f" Images={row['Images']}" - f",Image_Seconds={row['Image_Seconds']}" - # previous data had 4 decimal places of accuracy - f",GlanceGBSeconds={row['Glance_GB'] * row['Image_Seconds'] * row['Images']:.4f}" - - f" {end_time.timestamp():.0f}\n" - ) - return datastring - - -class ManilaAccounting(Job): - db_name="manila" - - @staticmethod - def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: - """ - transform manila data from source to get the total shared network storage used (GB seconds) per project - :rows: list of dictionaries containing manila data from source database - :end_time: python datetime - the end time of the job - used to timestamp the entry to sink - :instance: string annotation to pass to sink to indicate which source the data came from - """ - datastring = "" - for row in rows: - department = Job.get_department(row).replace(' ', r'\ ') - project = row['Project'].replace(' ', r'\ ') - - datastring += ( - f"Accounting" - f",instance={instance}" - f",AvailabilityZone={row['Availability_zone']}" - f",Project={project}" - f",Department={department}" - f",ManilaType={row['ManilaType']}" - f",ManilaShareType={row['Share_type']}" - f",YYYY-MM={end_time.strftime('%Y-%m')}" - - f" Shares={row['Shares']}" - f",Share_Seconds={row['Share_Seconds']}" - f",ManilaGBs={row['Share_GB'] * row['Share_Seconds'] * row['Shares']}" - - f" {end_time.timestamp():.0f}\n" - ) - return datastring - - -class NovaAccounting(Job): - db_name="nova" - - @staticmethod - def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: - """ - transform nova data from source to get total cost for running VMs on the cloud per project - - including flavor cost calculations and GPU cost calculations - :rows: list of dictionaries containing nova data from source database - :end_time: python datetime - the end time of the job - used to timestamp the entry to sink - :instance: string annotation to pass to sink to indicate which source the data came from - """ - datastring = "" - for row in rows: - department = Job.get_department(row).replace(' ', r'\ ') - project = row['Project'].replace(' ', r'\ ') - - instancetype = row["Charge_Unit"] - gpu_num = int(row["GPU_Num"] or 0) - vm_seconds = int(row["VM_Seconds"]) - vms = int(row["VMs"]) - vcpu = int(row["VCPU"]) - swap = int(row["Swap"]) - root_gb = int(row["Root_GB"]) - ephemeral_gb = int(row["Ephemeral_GB"]) - cost = float( - gpu_num * vm_seconds * float(row["Per_Unit_Cost"]) / 3600 - if gpu_num > 0 - else vcpu * vm_seconds * float(row["Per_Unit_Cost"]) / 3600 - ) - - datastring += ( - f"Accounting" - f",instance={instance}" - f",AvailabilityZone={row['AvailabilityZone']}" - f",Project={project}" - f",Department={department}" - f",Flavor={row['Flavor'].replace('.', '_')}" - f",FlavorPrefix={row['Flavor'].split('.')[0]}" - f",InstanceType={instancetype}" - f",YYYY-MM={end_time:%Y-%m}" - f",Charge_Unit={row['Charge_Unit']}" - - f" VMs={vms}" - f",VM_Seconds={vm_seconds}" - f",Memory_MB_Seconds={row['Memory_MB'] * vm_seconds}" - f",Memory_MBs={row['Memory_MB'] * vms}" - f",VCPU_Seconds={vcpu * vm_seconds}" - f",VCPUs={vcpu * vms}" - f",Swap_Seconds={swap * vm_seconds}" - f",Swaps={swap * vms}" - f",Root_GB_Seconds={root_gb * vm_seconds}" - f",Root_GBs={root_gb * vms}" - f",Ephemeral_GB_Seconds={ephemeral_gb * vm_seconds}" - f",Ephemeral_GBs={ephemeral_gb * vms}" - f",COST={cost}" - f",GPUs={gpu_num * vms}" - f",GPU_Seconds={gpu_num * vm_seconds}" - - f" {end_time.timestamp():.0f}\n" - ) - return datastring diff --git a/OpenStack-accounting/thecount/parsers/__init__.py b/OpenStack-accounting/thecount/parsers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/OpenStack-accounting/thecount/parsers/base_parser.py b/OpenStack-accounting/thecount/parsers/base_parser.py new file mode 100644 index 00000000..e2ffc54c --- /dev/null +++ b/OpenStack-accounting/thecount/parsers/base_parser.py @@ -0,0 +1,61 @@ +from abc import ABC, abstractmethod +from typing import Dict, Any, List +from datetime import datetime + +from thecount.source import Source +from thecount.sink import Sink + + +class BaseParser(ABC): + """ + Abstract base class for all accounting jobs + """ + + db_name = "" + + def run( + self, + source: Source, + sink: Sink, + start_time: datetime, + end_time: datetime, + dry_run=False, + ) -> None: + """ + function that fetches data from the source, parses and transforms it, then passes it to the sink + source: a Source object is configured to fetch accounting data + - implements the fetch() method + sink: a Sink object is configured to write transformed/parsed accounting data somewhere + - implements the write() method + start_time: python datetime - the start time of the job + end_time: python datetime - the end time of the job + dry_run: boolean - whether to run the job in dry run mode - i.e. print to console instead of sending + it to source (useful for debugging) + """ + rows = source.fetch( + database=self.db_name, + start_time=start_time, + end_time=end_time, + ) + out = self._transform(rows, end_time, sink.instance) + if dry_run: + print(out) + else: + sink.write(out) + + @staticmethod + def get_department(result: Dict[str, Any]) -> str: + """ + helper function returns an appropriate accounting department for each project from source + """ + department = result["Department"] + if "rally" in result["Project"]: + department = "STFC Cloud" + elif "default" in result["Department"].casefold(): + department = result["Project"] + return department + + @staticmethod + @abstractmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ABSTRACT CLASS - to be implemented by subclasses""" diff --git a/OpenStack-accounting/thecount/parsers/cinder_parser.py b/OpenStack-accounting/thecount/parsers/cinder_parser.py new file mode 100644 index 00000000..68251a26 --- /dev/null +++ b/OpenStack-accounting/thecount/parsers/cinder_parser.py @@ -0,0 +1,36 @@ +from typing import List, Dict, Any +from datetime import datetime + +from thecount.parsers.base_parser import BaseParser + + +class CinderParser(BaseParser): + db_name = "cinder" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform cinder data from source to get total amount of usage (GB seconds) for block-storage per project + :rows: list of dictionaries containing cinder data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to state which source the data came from + """ + datastring = "" + for row in rows: + department = BaseParser.get_department(row).replace(" ", r"\ ") + project = row["Project"].replace(" ", r"\ ") + + datastring += ( + f"Accounting" + f",instance={instance}" + f",AvailabilityZone={row['AvailabilityZone']}" + f",Project={project}" + f",Department={department}" + f",CinderType={row['CinderType']}" + f",YYYY-MM={end_time.strftime('%Y-%m')}" + f" Volumes={row['Volumes']}" + f",Volume_Seconds={row['Volume_Seconds']}" + f",CinderGBs={row['Volume_GB'] * row['Volume_Seconds'] * row['Volumes']}" + f" {end_time.timestamp():.0f}\n" + ) + return datastring diff --git a/OpenStack-accounting/thecount/parsers/glance_parser.py b/OpenStack-accounting/thecount/parsers/glance_parser.py new file mode 100644 index 00000000..d5ddfdbc --- /dev/null +++ b/OpenStack-accounting/thecount/parsers/glance_parser.py @@ -0,0 +1,37 @@ +from typing import List, Dict, Any +from datetime import datetime + +from thecount.parsers.base_parser import BaseParser + + +class GlanceParser(BaseParser): + db_name = "glance" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform glance data from source to get total image/snapshot storage usage (GB seconds) per project + :rows: list of dictionaries containing glance data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to state which source the data came from + """ + datastring = "" + for row in rows: + department = BaseParser.get_department(row).replace(" ", r"\ ") + project = row["Project"].replace(" ", r"\ ") + + datastring += ( + f"Accounting" + f",instance={instance}" + f",Project={project}" + f",Department={department}" + f",StorageBackend={row['StorageBackend']}" + f",GlanceType={row['GlanceType']}" + f",YYYY-MM={end_time.strftime('%Y-%m')}" + f" Images={row['Images']}" + f",Image_Seconds={row['Image_Seconds']}" + # previous data had 4 decimal places of accuracy + f",GlanceGBSeconds={row['Glance_GB'] * row['Image_Seconds'] * row['Images']:.4f}" + f" {end_time.timestamp():.0f}\n" + ) + return datastring diff --git a/OpenStack-accounting/thecount/parsers/manila_parser.py b/OpenStack-accounting/thecount/parsers/manila_parser.py new file mode 100644 index 00000000..40153d87 --- /dev/null +++ b/OpenStack-accounting/thecount/parsers/manila_parser.py @@ -0,0 +1,37 @@ +from typing import List, Dict, Any +from datetime import datetime + +from thecount.parsers.base_parser import BaseParser + + +class ManilaParser(BaseParser): + db_name = "manila" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform manila data from source to get the total shared network storage used (GB seconds) per project + :rows: list of dictionaries containing manila data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to indicate which source the data came from + """ + datastring = "" + for row in rows: + department = BaseParser.get_department(row).replace(" ", r"\ ") + project = row["Project"].replace(" ", r"\ ") + + datastring += ( + f"Accounting" + f",instance={instance}" + f",AvailabilityZone={row['Availability_zone']}" + f",Project={project}" + f",Department={department}" + f",ManilaType={row['ManilaType']}" + f",ManilaShareType={row['Share_type']}" + f",YYYY-MM={end_time.strftime('%Y-%m')}" + f" Shares={row['Shares']}" + f",Share_Seconds={row['Share_Seconds']}" + f",ManilaGBs={row['Share_GB'] * row['Share_Seconds'] * row['Shares']}" + f" {end_time.timestamp():.0f}\n" + ) + return datastring diff --git a/OpenStack-accounting/thecount/parsers/nova_parser.py b/OpenStack-accounting/thecount/parsers/nova_parser.py new file mode 100644 index 00000000..2df6fa4e --- /dev/null +++ b/OpenStack-accounting/thecount/parsers/nova_parser.py @@ -0,0 +1,69 @@ +from typing import List, Dict, Any +from datetime import datetime + +from thecount.parsers.base_parser import BaseParser + + +class NovaParser(BaseParser): + db_name = "nova" + + @staticmethod + def _transform(rows: List[Dict[str, Any]], end_time: datetime, instance) -> str: + """ + transform nova data from source to get total cost for running VMs on the cloud per project + - including flavor cost calculations and GPU cost calculations + :rows: list of dictionaries containing nova data from source database + :end_time: python datetime - the end time of the job - used to timestamp the entry to sink + :instance: string annotation to pass to sink to indicate which source the data came from + """ + datastring = "" + for row in rows: + department = BaseParser.get_department(row).replace(" ", r"\ ") + project = row["Project"].replace(" ", r"\ ") + + # pylint: disable=too-many-locals + # this is a complicated transform function, + # using lots of local variables makes it easier to understand + instancetype = row["Charge_Unit"] + gpu_num = int(row["GPU_Num"] or 0) + vm_seconds = int(row["VM_Seconds"]) + vms = int(row["VMs"]) + vcpu = int(row["VCPU"]) + swap = int(row["Swap"]) + root_gb = int(row["Root_GB"]) + ephemeral_gb = int(row["Ephemeral_GB"]) + cost = float( + gpu_num * vm_seconds * float(row["Per_Unit_Cost"]) / 3600 + if gpu_num > 0 + else vcpu * vm_seconds * float(row["Per_Unit_Cost"]) / 3600 + ) + + datastring += ( + f"Accounting" + f",instance={instance}" + f",AvailabilityZone={row['AvailabilityZone']}" + f",Project={project}" + f",Department={department}" + f",Flavor={row['Flavor'].replace('.', '_')}" + f",FlavorPrefix={row['Flavor'].split('.')[0]}" + f",InstanceType={instancetype}" + f",YYYY-MM={end_time:%Y-%m}" + f",Charge_Unit={row['Charge_Unit']}" + f" VMs={vms}" + f",VM_Seconds={vm_seconds}" + f",Memory_MB_Seconds={row['Memory_MB'] * vm_seconds}" + f",Memory_MBs={row['Memory_MB'] * vms}" + f",VCPU_Seconds={vcpu * vm_seconds}" + f",VCPUs={vcpu * vms}" + f",Swap_Seconds={swap * vm_seconds}" + f",Swaps={swap * vms}" + f",Root_GB_Seconds={root_gb * vm_seconds}" + f",Root_GBs={root_gb * vms}" + f",Ephemeral_GB_Seconds={ephemeral_gb * vm_seconds}" + f",Ephemeral_GBs={ephemeral_gb * vms}" + f",COST={cost}" + f",GPUs={gpu_num * vms}" + f",GPU_Seconds={gpu_num * vm_seconds}" + f" {end_time.timestamp():.0f}\n" + ) + return datastring diff --git a/OpenStack-accounting/thecount/sink.py b/OpenStack-accounting/thecount/sink.py index 43cceb2e..23a54672 100644 --- a/OpenStack-accounting/thecount/sink.py +++ b/OpenStack-accounting/thecount/sink.py @@ -1,22 +1,22 @@ import logging from configparser import ConfigParser from typing import Optional - + from influxdb import InfluxDBClient from influxdb.exceptions import InfluxDBClientError, InfluxDBServerError - + logger = logging.getLogger(__name__) - -TIMEOUT = (5, 30) # (connect, read) in seconds + +TIMEOUT = 30 # how long to wait to connect to influxdb before timing out (in seconds) DEFAULT_PORT = 8086 - - + + class Sink: def __init__(self, config_fp: str): - """ constructor method """ + """constructor method""" self._client: Optional[InfluxDBClient] = None self._load_config(config_fp) - + def _load_config(self, config_fp: str) -> None: """ helper function to load in thecount config file and extract sink-related config info @@ -25,7 +25,7 @@ def _load_config(self, config_fp: str) -> None: parser = ConfigParser(interpolation=None) if not parser.read(config_fp, encoding="utf-8"): raise FileNotFoundError(f"config not found: {config_fp}") - + host = parser.get("sink", "host") self._host, _, port = host.partition(":") self._port = int(port) if port else DEFAULT_PORT @@ -33,7 +33,7 @@ def _load_config(self, config_fp: str) -> None: self.instance = parser.get("sink", "instance") self._username = parser.get("sink", "username") self._password = parser.get("sink", "password") - + def __enter__(self) -> "Sink": """ Sink is managed via context manager - when context is opened, create and maintain an @@ -46,13 +46,15 @@ def __enter__(self) -> "Sink": username=self._username, password=self._password, database=self._database, + ssl=True, + verify_ssl=False, timeout=TIMEOUT, ) logger.debug( "sink client opened for %s:%d/%s", self._host, self._port, self._database ) return self - + def __exit__(self, *exc) -> bool: """ Sink is managed via context manager - when context is closed, close the influxdb client @@ -61,7 +63,7 @@ def __exit__(self, *exc) -> bool: self._client.close() self._client = None return False - + def write(self, data_string: str) -> None: """ write data to sink influxdb @@ -69,15 +71,15 @@ def write(self, data_string: str) -> None: """ if self._client is None: raise RuntimeError("Sink.write() called outside a `with` block") - + lines = [line for line in data_string.splitlines() if line.strip()] if not lines: logger.info("nothing to write") return - + try: self._client.write_points(lines, time_precision="s", protocol="line") except (InfluxDBClientError, InfluxDBServerError) as exc: raise RuntimeError(f"influx write failed: {exc}") from exc - + logger.info("wrote %d lines", len(lines)) diff --git a/OpenStack-accounting/thecount/source.py b/OpenStack-accounting/thecount/source.py index 5d3510f2..f79e7400 100644 --- a/OpenStack-accounting/thecount/source.py +++ b/OpenStack-accounting/thecount/source.py @@ -20,7 +20,7 @@ class Source: """ def __init__(self, config_fp: str): - """ constructor class """ + """constructor class""" self._engines: Optional[Dict[str, sqlalchemy.Engine]] = None self._load_config(config_fp) @@ -79,7 +79,7 @@ def _engine(self, database: str) -> sqlalchemy.Engine: return self._engines[database] def fetch( - self, database: str, start_time: datetime, end_time: datetime + self, database: str, start_time: datetime, end_time: datetime ) -> list[dict]: """ Call the accounting sql procedure for a single interval. diff --git a/OpenStack-accounting/thecount/structs.py b/OpenStack-accounting/thecount/structs.py index 5a5dedc1..2eb25132 100644 --- a/OpenStack-accounting/thecount/structs.py +++ b/OpenStack-accounting/thecount/structs.py @@ -4,7 +4,8 @@ from datetime import datetime, timedelta from thecount.sink import Sink from thecount.source import Source -from thecount.jobs import Job +from thecount.parsers.base_parser import BaseParser + @dataclass class RunDetails: @@ -16,7 +17,8 @@ class RunDetails: :interval: timedelta object - size of window to collate accounting info between :dry_run: bool - if True, will not write to Sink, output to console instead - for testing """ - jobs: List[Job] + + jobs: List[BaseParser] sink: Sink source: Source interval: timedelta diff --git a/OpenStack-accounting/uv.lock b/OpenStack-accounting/uv.lock index 885776e3..d5b844f5 100644 --- a/OpenStack-accounting/uv.lock +++ b/OpenStack-accounting/uv.lock @@ -429,6 +429,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "influxdb" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/d4/4c1bd3a8f85403fad3137a7e44f7882b0366586b7c27d12713493516f1c7/influxdb-5.3.2.tar.gz", hash = "sha256:58c647f6043712dd86e9aee12eb4ccfbbb5415467bc9910a48aa8c74c1108970", size = 70792, upload-time = "2024-04-18T21:45:09.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/00/d0ea35f823c46a5cd4fa80d933176d251573601d920737ce8a85df7664db/influxdb-5.3.2-py2.py3-none-any.whl", hash = "sha256:00d86b18a968d011b2eee39ec3b2ae941b1dcf7086bc7211e675914623caffcd", size = 79391, upload-time = "2024-04-18T21:45:06.471Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -456,6 +472,101 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/40/181c8944b28f779ed0b2587d24cc0ccf1bc87248204105327140aa20d63d/msgpack-1.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7afa5431f6f3487c584187ca6c8e2a34e9b106529893b3e720eabb068f6ac970", size = 83554, upload-time = "2026-08-27T10:01:34.182Z" }, + { url = "https://files.pythonhosted.org/packages/03/f7/a6bf145f7d3eb734b3d97fa295f8007a586799ef56b456c8b27bff62caac/msgpack-1.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a9b4cf3685a135666d27d0d7a73fece74e2fad01d9b508fded89e843512f0e90", size = 83857, upload-time = "2026-08-27T10:01:35.816Z" }, + { url = "https://files.pythonhosted.org/packages/93/1a/77d32a60a80ee67016e77b13bec07e85f1929a92f046da044591c7eb01c9/msgpack-1.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4710d881d8fb047deed2485707409116722af2b992d3fefd73c7667c4e350839", size = 398390, upload-time = "2026-08-27T10:01:37.693Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f5/ae93f85b063d744731cb285528210cd950333b167cacc7b2f96e1420a475/msgpack-1.2.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58ce37a4a54577115922385d37201d9a44d66d0167dfbbf4770a2e9bf8ea7ba3", size = 407183, upload-time = "2026-08-27T10:01:39.499Z" }, + { url = "https://files.pythonhosted.org/packages/17/92/d91a08a913a3bbafbede5b9dbf48e4e517f7d92510877b6b02730060ea85/msgpack-1.2.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86f173a584f72f6164801f31866d22a581f60c991572cf922aed9ab8eb422b77", size = 375978, upload-time = "2026-08-27T10:01:41.234Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a4/27400c101a96115fe8484cad57b6c5eb4f9ffba080a1d9be62ec9174bf67/msgpack-1.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e05a94a0442de86818a30281c6cc2cb9cc7aa148386fd3541c4d4774b73cb3a9", size = 389566, upload-time = "2026-08-27T10:01:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/44/92/541e9fa4623587767623788b38d11fc78d402acb1421962a13d3ace48bef/msgpack-1.2.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9bd3d1557c3fe1a095068210708a03e3e4795973392af6f4047060e70abd9a6c", size = 372583, upload-time = "2026-08-27T10:01:44.572Z" }, + { url = "https://files.pythonhosted.org/packages/0e/03/c1e0035e1f923f548b7016a2fef5afea431cdae95d397c5aa52ed75dde05/msgpack-1.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:46ec851571d8f1b6e29794ebb9dd36f785008da6d14f57c702e60781d6caf648", size = 404507, upload-time = "2026-08-27T10:01:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/7efba0fd604059f0dc8f57ba2867bda4d57bab6921a12e975ecfbd49284c/msgpack-1.2.2-cp310-cp310-win32.whl", hash = "sha256:1f3af0baafd184436501004828bb3df64eeb2fc49dfe9d89abcf604956094563", size = 64848, upload-time = "2026-08-27T10:01:47.797Z" }, + { url = "https://files.pythonhosted.org/packages/57/1d/b41e96ff441d46890b9d5982959aea4d15c5d322f92740dbef16e9dfa908/msgpack-1.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:82b1bdf293267afaadcc608b125e7fc6576bb0785a60c4fa7d07c7ab76ed76ec", size = 70927, upload-time = "2026-08-27T10:01:49.199Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/a05ba8f84c5951c9aec2a19c1c81f6c4a67b8bec80af604ac5b23ccfa019/msgpack-1.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d7fb25b4442fae0cb2590272d06ab4f6caa526ee36a994edb81e946b874813e", size = 83498, upload-time = "2026-08-27T10:01:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/0f/df/e20bcf5c149890545334743b212eb4b82e1a25fe0a34f99753a1755bfab5/msgpack-1.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fe374ba76eb0ecca13a1703daa8fa85825a6ddddbb52d4c1a732fa524194683", size = 83896, upload-time = "2026-08-27T10:01:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c3/00dcd902d66a641b9ba350783feb482ea5c1ca4a7ff6629db0c10c0ea982/msgpack-1.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9b0c1f2aa7b0026b4bd50718100e8b04175e4f36e160aa852502377b5e572e7", size = 413259, upload-time = "2026-08-27T10:01:53.296Z" }, + { url = "https://files.pythonhosted.org/packages/93/15/17374efe9793f5332c7d4727ab40539f95a1dc9df653531795daca8c4281/msgpack-1.2.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f11e09f10210a91c169e39c7a5a1f9090eaa73ad75555fafad5023c3053c47ba", size = 422907, upload-time = "2026-08-27T10:01:54.786Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/2b567d684f912fedcefe3f7c37de604716ffa99336bd432688f9f040df92/msgpack-1.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b1415d02e9bf722672af8a90f90813265a0cd0b14163187261e54a5592bc949", size = 389248, upload-time = "2026-08-27T10:01:56.55Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/d8533fed473cc3e309a701e851d0e5fe36ada5552a3899025f5c69fbe877/msgpack-1.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:42fd9260416885b4815caca5bdd14dfd5dda6cdade732d6c09104ef8f6228761", size = 407099, upload-time = "2026-08-27T10:01:58.357Z" }, + { url = "https://files.pythonhosted.org/packages/d6/1b/57906337bfee0ead554571dc203ea17c3fad26d51e5eca6271ecd983f73b/msgpack-1.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:336525cc2688e43ea77dfb1a4ce012c8cde561835913801dbfcfdcf4111d8abb", size = 387201, upload-time = "2026-08-27T10:02:00.109Z" }, + { url = "https://files.pythonhosted.org/packages/de/0f/5d1e6d68e516621697a9262b24917d678793e838cf3f331ed4656b3e959d/msgpack-1.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cdb6cc6e1127d15879c47a8b3270716243da82d3e7feab1f5946872c75b3d60f", size = 420765, upload-time = "2026-08-27T10:02:01.573Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a6/07f9a4f3324d55c3567ab2a7e8d5325291bc95a31a374bb390a21b7c4e24/msgpack-1.2.2-cp311-cp311-win32.whl", hash = "sha256:cf66fb38703e61a486b01b56d43bb1f50698fbe99b6bd90feba10f24fab60b3b", size = 64785, upload-time = "2026-08-27T10:02:03.01Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f748f0d59f355d196e71a0b32d48d386a9bd311f94d954e666cf7e5b2572/msgpack-1.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:0883a1578168929fd1640fbbc4614773f1a130e419a8a817dc2918d9af1b651c", size = 71258, upload-time = "2026-08-27T10:02:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/10d979c4e76b18a9b9ebbd6499ff863474ffe5955028ea27e09b66f6833c/msgpack-1.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:4955accbd87f27beebef5f3ecc27503aa74cb016fb4f640868e749fd93194a35", size = 65860, upload-time = "2026-08-27T10:02:05.735Z" }, + { url = "https://files.pythonhosted.org/packages/31/78/90c15bebb1a72667349ca62d4507e9d9369e7f8f76b95f490b823d3622e5/msgpack-1.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc", size = 84275, upload-time = "2026-08-27T10:02:07.106Z" }, + { url = "https://files.pythonhosted.org/packages/88/88/c2b6d8e81571da87aa232c0e34a3f3a0e618e6235892065ec82d1d81fc7a/msgpack-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3", size = 83970, upload-time = "2026-08-27T10:02:08.488Z" }, + { url = "https://files.pythonhosted.org/packages/da/c0/d3ede9f5d16acb4c05a9281859f1e99ef9f877a928eb78454c37f70db001/msgpack-1.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba", size = 409401, upload-time = "2026-08-27T10:02:09.877Z" }, + { url = "https://files.pythonhosted.org/packages/41/f0/29f591bea185616cf417645ac03bd3ad9b317483ad8572160e325f7fe777/msgpack-1.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923", size = 420619, upload-time = "2026-08-27T10:02:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8e/c70c8c9180c5ddf4440eb8658ebead98e22e7686fbf84f6b165031430750/msgpack-1.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f", size = 379747, upload-time = "2026-08-27T10:02:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/50/9a/f10ce11fa62700c9ab87a22e65b9ca272f7f673ddd31aeb2de6ae272ad35/msgpack-1.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367", size = 398944, upload-time = "2026-08-27T10:02:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/82/fe/d7be978456ff8552e69a8e270d882e7530e01513c096b293d83df03753ea/msgpack-1.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc", size = 373979, upload-time = "2026-08-27T10:02:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/be/af/91b0d8d3fb3063e259daee3ea8515cea6282f68f4b0e5f0b6fea25762c6e/msgpack-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3", size = 417781, upload-time = "2026-08-27T10:02:18.293Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3c/ce8e9efe1fd9e95c78b3705e4300ba7feba3dc6c00fb76259895db155518/msgpack-1.2.2-cp312-cp312-win32.whl", hash = "sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4", size = 65267, upload-time = "2026-08-27T10:02:19.869Z" }, + { url = "https://files.pythonhosted.org/packages/85/98/a33b8b4af14e3476bb0da1b8c36ef7a0f28dcf95db1c5e68ff88cb89d591/msgpack-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3", size = 72275, upload-time = "2026-08-27T10:02:21.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/2f323a33a6aba5bd4b2d8b430e4fab21d92cd91c093b49ee287bc166ee54/msgpack-1.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640", size = 65488, upload-time = "2026-08-27T10:02:22.575Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" }, + { url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" }, + { url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/3e92c403346652cabd08cb8faceef847bae917ea3b3c81b64a5b6d09ed41/msgpack-1.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04", size = 84315, upload-time = "2026-08-27T10:02:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/8efe6dd96a12ab043930cb4cffb40b6e7f061491d6ec7a3d2b75ef1fda42/msgpack-1.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77", size = 84634, upload-time = "2026-08-27T10:02:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/996573095bf7b038c04dd65ddbc4f1a4d381b0f7a44ff9186f3c7b8325c2/msgpack-1.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe", size = 404194, upload-time = "2026-08-27T10:02:44.096Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4e/46f5a5d949dbd054dab60cb15aac7ac6ae6774c134532893414689bf2f53/msgpack-1.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f", size = 412343, upload-time = "2026-08-27T10:02:45.747Z" }, + { url = "https://files.pythonhosted.org/packages/da/e8/739a94197358a313307e6e9e7d8d22ef66add39222de911a44161aa96920/msgpack-1.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea", size = 372620, upload-time = "2026-08-27T10:02:47.578Z" }, + { url = "https://files.pythonhosted.org/packages/03/d4/09b92e1fcdccea9466bfae45455367ac52362ae445d96a602e51b7a8df73/msgpack-1.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b", size = 394603, upload-time = "2026-08-27T10:02:49.172Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/d11bd6f258a60703dcdc7a3772818ad0c2f602ee4c2acfb24088c6c3ebc3/msgpack-1.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5", size = 372666, upload-time = "2026-08-27T10:02:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/fbbbac0c6e5fbb9d51abc23e3b5fe8620f5c01e0588797cf664a623bb9e1/msgpack-1.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54", size = 410889, upload-time = "2026-08-27T10:02:52.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/60/8366558da954095e04e7fbc351f9387d87a682feaee9a235ceda966f794b/msgpack-1.2.2-cp314-cp314-win32.whl", hash = "sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248", size = 66774, upload-time = "2026-08-27T10:02:53.984Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3d/1ce873c8057c65e4fbb076ffe1c99c9ae39d90a00a2540d7b06c652a292f/msgpack-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc", size = 73424, upload-time = "2026-08-27T10:02:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/d5/55/e36f2a33e38657f33850d74e0bf256838a0d45802c298cc501a32bffcc08/msgpack-1.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8", size = 67657, upload-time = "2026-08-27T10:02:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/64/58/7e764b957bae80ae281a9cb28761068c8bae8d5c6ac0873e43cc69d176c7/msgpack-1.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650", size = 86594, upload-time = "2026-08-27T10:02:57.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f0/250f5985b6ee533e60d357571a808aaae03c54118294dc3db7158e27feb1/msgpack-1.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c", size = 87374, upload-time = "2026-08-27T10:02:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2c/126ec8f187877c5f688631c543d1d3a3d75b2e66b83fb9de3ed7c13a39b6/msgpack-1.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3", size = 428157, upload-time = "2026-08-27T10:03:00.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d2d81d50aaedb14147d01f22094185794db3ad8a8791b60afacba0627c89/msgpack-1.2.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3", size = 426669, upload-time = "2026-08-27T10:03:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fc/f7d484ee5b572719608e7ffad569bea22ff11309a96ca2fae85eec94226b/msgpack-1.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22", size = 380625, upload-time = "2026-08-27T10:03:04.244Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c4/b924cbd5516676f4e612329f18602a833bd055ffbe27f808eeba0f01bfea/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839", size = 411328, upload-time = "2026-08-27T10:03:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/27/9d/0c1d9683a951a80f270c3b7dac1022c18b9307617344dd44d904135d5e12/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929", size = 377892, upload-time = "2026-08-27T10:03:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/bf22338cdd22e0b40c8f28468cea5f3d9c320244c095d8303364bc012c41/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7", size = 419426, upload-time = "2026-08-27T10:03:09Z" }, + { url = "https://files.pythonhosted.org/packages/7d/42/6d02c19a01abd8d7ce817c321d2ee6af1a8e24d584dca619d1b6576a83bf/msgpack-1.2.2-cp314-cp314t-win32.whl", hash = "sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e", size = 71810, upload-time = "2026-08-27T10:03:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/fda3a204415dab0a8c0db5461ef7205416ea52bd8581c5cafd361be07f3b/msgpack-1.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36", size = 78919, upload-time = "2026-08-27T10:03:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/63/d4/4b4b0ef25a86deca91feaf7252ca885ba4f2ada40461379120122a04fe96/msgpack-1.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f", size = 71925, upload-time = "2026-08-27T10:03:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/4b44bc8f3243ef8cf9cb5368c17a299d45b9df858f6dfdd98a0482dbbb37/msgpack-1.2.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5", size = 84293, upload-time = "2026-08-27T10:03:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/80/05/c992bb65744665a41b5bf531fc0e1619bae0901f57738228ded90023c151/msgpack-1.2.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986", size = 84490, upload-time = "2026-08-27T10:03:16.12Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bf/7f53b9e6709a4df7f9b9b81dc65f9dfaa32caf65bee94986ec2cb8fa07f1/msgpack-1.2.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516", size = 405332, upload-time = "2026-08-27T10:03:17.692Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5a/305c4dca14b50d0b51fb88ef04ec125b8f0be3e2ce730dcc62dbaa651cc5/msgpack-1.2.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21", size = 416798, upload-time = "2026-08-27T10:03:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/a645102b4cdfd9a94201cac4e900e9c1429fc16d86aa311c06eef82528c9/msgpack-1.2.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9", size = 377312, upload-time = "2026-08-27T10:03:20.928Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/c56d8d086d3fb1077bb48092b158b5ea2eee08b279e10c191275f13bc980/msgpack-1.2.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a", size = 395182, upload-time = "2026-08-27T10:03:22.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b5/3d46ba367a565e536d8d2a61eebcee71b1dc803da3ce74a22313b573d6fa/msgpack-1.2.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5", size = 377945, upload-time = "2026-08-27T10:03:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2c/d5d2df273ed5306357da25b69400fd8d7a53c4d87d8976604b677484d61c/msgpack-1.2.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b", size = 413341, upload-time = "2026-08-27T10:03:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fb/32613bced3cad47b40b1b73dd04d687121349d83f748efc2575929121903/msgpack-1.2.2-cp315-cp315-win32.whl", hash = "sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf", size = 66730, upload-time = "2026-08-27T10:03:27.294Z" }, + { url = "https://files.pythonhosted.org/packages/74/56/d86171f7251015e9312e5a7f9fdd4cf89752fc2114b88fed453d2a040c66/msgpack-1.2.2-cp315-cp315-win_amd64.whl", hash = "sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff", size = 73477, upload-time = "2026-08-27T10:03:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/13/1a/56b90f6defef61700b86baca3637c15f62ac0f9b21ab0f16613ab9d1f101/msgpack-1.2.2-cp315-cp315-win_arm64.whl", hash = "sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808", size = 67660, upload-time = "2026-08-27T10:03:29.895Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/12751ca0d8ec874701b54c392c2b19f51af8dd1de40a92a10e356f0aaf58/msgpack-1.2.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8", size = 86462, upload-time = "2026-08-27T10:03:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/cf6d12a3d709fe5f9771dd917c35e6ebcd55597a5b792287382fde056c95/msgpack-1.2.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84", size = 87412, upload-time = "2026-08-27T10:03:32.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0d/0aac5752d1708dcb458f8754db34a4999514db3df2d2b798b9381293f638/msgpack-1.2.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b", size = 422057, upload-time = "2026-08-27T10:03:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/81/30/70f281a3685b04aaf235a5237da11b978a02a865a5a479186205177ad676/msgpack-1.2.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782", size = 422696, upload-time = "2026-08-27T10:03:35.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/f76e8425efb0aa38988cd778ae290bfa120491d80d26872d88bb52fedb3f/msgpack-1.2.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f", size = 376495, upload-time = "2026-08-27T10:03:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/0809aa9b52b2868f7d01862dc14073708f0440421a65197b48453480034c/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695", size = 404683, upload-time = "2026-08-27T10:03:38.87Z" }, + { url = "https://files.pythonhosted.org/packages/02/d2/4e5ac915ba120172d210ef00165c5e6276c8a65db3a4a5cf36e946b83e23/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23", size = 375087, upload-time = "2026-08-27T10:03:40.486Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e3/8051d53e5495c87c6cf27eb42fb680361017037f87f322bdaf525f71e4a2/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212", size = 414421, upload-time = "2026-08-27T10:03:42.308Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4e/13783aa7c17414d7186c72c49bc718366f75e49f0ea58d4f81cb63ac3187/msgpack-1.2.2-cp315-cp315t-win32.whl", hash = "sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc", size = 71790, upload-time = "2026-08-27T10:03:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/1d02994c7ae2603c98100984428ff0f67443572133bc18eca6058f732c1b/msgpack-1.2.2-cp315-cp315t-win_amd64.whl", hash = "sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d", size = 78766, upload-time = "2026-08-27T10:03:45.036Z" }, + { url = "https://files.pythonhosted.org/packages/60/54/89ed16e6f966a050dc78b0e94a545025211b07ce9f4bdfe07dff70c03fc2/msgpack-1.2.2-cp315-cp315t-win_arm64.whl", hash = "sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754", size = 71819, upload-time = "2026-08-27T10:03:46.375Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -570,6 +681,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -609,6 +732,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -624,6 +756,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.51" @@ -684,6 +825,7 @@ name = "thecount" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "influxdb" }, { name = "pymysql" }, { name = "requests" }, { name = "sqlalchemy" }, @@ -700,6 +842,7 @@ test = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'test'", specifier = ">=26.5.1" }, + { name = "influxdb" }, { name = "pylint", marker = "extra == 'test'", specifier = ">=4.0.6" }, { name = "pymysql" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=9.1.1" }, From 1fa3e42ea8524e242bca12d23525f3ecbf10005f Mon Sep 17 00:00:00 2001 From: AnishMudaraddi Date: Wed, 2 Sep 2026 15:43:06 +0100 Subject: [PATCH 07/11] use env vars to set credentials for source and sink provides additional security and prevents passwords from accidentally being leaked --- OpenStack-accounting/Dockerfile | 9 ++++ OpenStack-accounting/README.md | 27 +++++++++++ OpenStack-accounting/pyproject.toml | 2 +- OpenStack-accounting/tests/test_cli.py | 12 ++--- OpenStack-accounting/tests/test_parsers.py | 2 +- OpenStack-accounting/tests/test_sink.py | 55 ++++++++++++++++------ OpenStack-accounting/tests/test_source.py | 48 +++++++++++++++---- OpenStack-accounting/thecount.conf | 8 ++++ OpenStack-accounting/thecount.conf.example | 10 ---- OpenStack-accounting/thecount/__init__.py | 4 +- OpenStack-accounting/thecount/__main__.py | 4 +- OpenStack-accounting/thecount/sink.py | 26 +++++++--- OpenStack-accounting/thecount/source.py | 32 +++++++++++-- 13 files changed, 184 insertions(+), 55 deletions(-) create mode 100644 OpenStack-accounting/thecount.conf delete mode 100644 OpenStack-accounting/thecount.conf.example diff --git a/OpenStack-accounting/Dockerfile b/OpenStack-accounting/Dockerfile index ffe80bd2..ac658839 100644 --- a/OpenStack-accounting/Dockerfile +++ b/OpenStack-accounting/Dockerfile @@ -28,6 +28,15 @@ RUN useradd --shell /bin/bash monitoring RUN chown -R monitoring:monitoring /app USER monitoring +ENV THE_COUNT_SOURCE_USERNAME=NOT_SET \ + THE_COUNT_SOURCE_PASSWORD=NOT_SET \ + THE_COUNT_SOURCE_HOST=NOT_SET \ + + THE_COUNT_SINK_USERNAME=NOT_SET \ + THE_COUNT_SINK_PASSWORD=NOT_SET \ + THE_COUNT_SINK_HOST=NOT_SET + + # Set the entrypoint to your package's main module ENTRYPOINT ["python", "-m", "thecount"] diff --git a/OpenStack-accounting/README.md b/OpenStack-accounting/README.md index 33e13f00..60bf5aa1 100644 --- a/OpenStack-accounting/README.md +++ b/OpenStack-accounting/README.md @@ -50,6 +50,26 @@ GRANT SELECT, EXECUTE ON `nova_cell0`.* TO `accounting-db`@`host.example.com`; We deploy this with kayobe in our kayobe config repo, but if you want to setup manually you can in two ways +### Provide a env file + +Setup a .env file that looks like this: + +```commandline +# ~/.env + +# --- MySQL source --- +THE_COUNT_SOURCE_USERNAME= +THE_COUNT_SOURCE_PASSWORD= +THE_COUNT_SOURCE_HOST= + +# --- InfluxDB sink --- +THE_COUNT_SINK_USERNAME=admin +THE_COUNT_SINK_PASSWORD= +THE_COUNT_SINK_HOST= +``` + +and populate with creds + ### As a container ```commandline @@ -64,6 +84,7 @@ on kayobe to get previous data ``` docker run \ --rm \ + --env-file ~/opt/kayobe/accounting-service/.env \ -v /opt/kayobe/accounting-service/:/etc/thecount/ \ --network host harbor.stfc.ac.uk/stfc-cloud/thecount:v1.0.0 \ --config-path /etc/thecount/thecount.conf \ @@ -86,6 +107,12 @@ pip install . then run it as a module +FIRST export your env vars first +``` +export $(grep -v '^#' .env | xargs) +``` + +Then run python module ``` python3 -m thecount --help diff --git a/OpenStack-accounting/pyproject.toml b/OpenStack-accounting/pyproject.toml index 4499e137..32cc254f 100644 --- a/OpenStack-accounting/pyproject.toml +++ b/OpenStack-accounting/pyproject.toml @@ -7,7 +7,7 @@ where = ["."] [project] name = "thecount" -version = "1.0.0" +version = "1.1.0" description = "python package for collecting stfc cloud accounting data" authors = [{name = "STFC Cloud Team", email = "cloud-support@stfc.ac.uk"}] readme = "README.md" diff --git a/OpenStack-accounting/tests/test_cli.py b/OpenStack-accounting/tests/test_cli.py index 6d4880ca..692b0f68 100644 --- a/OpenStack-accounting/tests/test_cli.py +++ b/OpenStack-accounting/tests/test_cli.py @@ -97,7 +97,7 @@ def test_single_job(): def test_job_values_split(): - """tests that providing multiple jobs works properly """ + """tests that providing multiple jobs works properly""" cfg = build_config(make_args(jobs=["cinder", "nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -105,7 +105,7 @@ def test_job_values_split(): def test_job_values_no_whitespace_tolerated(): - """ test that --job comma-spaced values with no whitespace is parsed correctly """ + """test that --job comma-spaced values with no whitespace is parsed correctly""" cfg = build_config(make_args(jobs=["cinder,nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -113,7 +113,7 @@ def test_job_values_no_whitespace_tolerated(): def test_job_values_whitespace_tolerated(): - """ test that --job comma-spaced values with whitespace is parsed properly """ + """test that --job comma-spaced values with whitespace is parsed properly""" cfg = build_config(make_args(jobs=["cinder,nova", "glance"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"], ALL_JOBS["glance"]] @@ -121,7 +121,7 @@ def test_job_values_whitespace_tolerated(): def test_job_values_empty_vals_ignored(): - """ test that multiple commas are handled correctly """ + """test that multiple commas are handled correctly""" cfg = build_config(make_args(jobs=["cinder,,nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -129,7 +129,7 @@ def test_job_values_empty_vals_ignored(): def test_duplicates_run_once(): - """ test that duplicate jobs are removed and only set to run once""" + """test that duplicate jobs are removed and only set to run once""" cfg = build_config(make_args(jobs=["cinder", "nova,cinder"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] @@ -137,7 +137,7 @@ def test_duplicates_run_once(): def test_unknown_job_named_in_error(): - """ test that unknown job names raise an error """ + """test that unknown job names raise an error""" with pytest.raises(ValueError, match="foo"): build_config(make_args(jobs=["cinder,foo"])) diff --git a/OpenStack-accounting/tests/test_parsers.py b/OpenStack-accounting/tests/test_parsers.py index 41defe47..b3f881a8 100644 --- a/OpenStack-accounting/tests/test_parsers.py +++ b/OpenStack-accounting/tests/test_parsers.py @@ -31,7 +31,7 @@ def test_job_output(dirname, mock_source, mock_sink): mocks source.fetch() returning contexts of input.txt, compares parsed output from _transform to exp.txt and ensures sink.write() is run correctly. - """ + """ job_cls = JOBS[dirname] fixture_dir = ROOT_TEST_DATA_FP / dirname rows = load_example_data(fixture_dir / "input.txt") diff --git a/OpenStack-accounting/tests/test_sink.py b/OpenStack-accounting/tests/test_sink.py index d221122e..87ebfd64 100644 --- a/OpenStack-accounting/tests/test_sink.py +++ b/OpenStack-accounting/tests/test_sink.py @@ -9,18 +9,19 @@ DATASTRING = "cpu,host=a value=1 1700000000\ncpu,host=b value=2 1700000000" PARSED_DATASTRING = ["cpu,host=a value=1 1700000000", "cpu,host=b value=2 1700000000"] +MOCK_SINK_ENV_VARS = { + "THE_COUNT_SINK_HOST": "localhost", + "THE_COUNT_SINK_USERNAME": "admin", + "THE_COUNT_SINK_PASSWORD": "pass", +} + @pytest.fixture(name="valid_config_file") -def valid_config_file_fn(tmp_path): - """ creates a mock valid thecount.conf config file """ +def valid_config_fn(tmp_path): + """creates a mock valid thecount.conf config file""" path = tmp_path / "t3.conf" path.write_text( - "[sink]\n" - "host = influx.example.com\n" - "database = accounting\n" - "instance = preprod\n" - "username = user\n" - "password = pass\n", + "[sink]\ndatabase = accounting\ninstance = preprod\nport = 3306\n", encoding="utf-8", ) return path @@ -48,10 +49,26 @@ def test_read_missing_key_raises(tmp_path): Sink(str(path)) +@pytest.mark.parametrize("omitted_env_var", MOCK_SINK_ENV_VARS.keys()) +def test_env_var_missing_raises(monkeypatch, omitted_env_var, valid_config_file): + """tests that omitting an required environment variable raises error""" + for env_var, val in MOCK_SINK_ENV_VARS.items(): + monkeypatch.setenv(env_var, val) + + # test that omitting env_var raises + monkeypatch.setenv(omitted_env_var, "") + + with pytest.raises(ValueError, match=omitted_env_var): + Sink(str(valid_config_file)) + + @patch("thecount.sink.InfluxDBClient") -def test_write_success(mock_influxdb_client, valid_config_file): +def test_write_success(mock_influxdb_client, monkeypatch, valid_config_file): """test write sets up proper influxdb client and writes correct data""" + for env_var, val in MOCK_SINK_ENV_VARS.items(): + monkeypatch.setenv(env_var, val) + mock_client = mock_influxdb_client.return_value with Sink(str(valid_config_file)) as sink: @@ -62,9 +79,9 @@ def test_write_success(mock_influxdb_client, valid_config_file): ) mock_influxdb_client.assert_called_once_with( - host="influx.example.com", - port=8086, - username="user", + host="localhost", + port="3306", + username="admin", password="pass", database="accounting", ssl=True, @@ -74,8 +91,12 @@ def test_write_success(mock_influxdb_client, valid_config_file): @patch("thecount.sink.InfluxDBClient") -def test_write_fail(mock_influxdb_client, valid_config_file): +def test_write_fail(mock_influxdb_client, monkeypatch, valid_config_file): """raises runtime error if influxdbclient write_points raises influxdbclient error""" + + for env_var, val in MOCK_SINK_ENV_VARS.items(): + monkeypatch.setenv(env_var, val) + mock_session = mock_influxdb_client.return_value mock_session.write_points.side_effect = InfluxDBClientError("foo") @@ -92,8 +113,14 @@ def test_write_fail(mock_influxdb_client, valid_config_file): @patch("thecount.sink.InfluxDBClient") -def test_write_empty_datastring_does_nothing(mock_influxdb_client, valid_config_file): +def test_write_empty_datastring_does_nothing( + mock_influxdb_client, monkeypatch, valid_config_file +): """tests that write doesn't post anything if provided an empty datastring""" + + for env_var, val in MOCK_SINK_ENV_VARS.items(): + monkeypatch.setenv(env_var, val) + mock_session = mock_influxdb_client.return_value with Sink(str(valid_config_file)) as sink: diff --git a/OpenStack-accounting/tests/test_source.py b/OpenStack-accounting/tests/test_source.py index 13406362..6f0511b0 100644 --- a/OpenStack-accounting/tests/test_source.py +++ b/OpenStack-accounting/tests/test_source.py @@ -9,22 +9,30 @@ START = datetime(2026, 1, 1, 9, 30, tzinfo=timezone.utc) END = datetime(2026, 1, 2, 10, 45, tzinfo=timezone.utc) +MOCK_SOURCE_ENV_VARS = { + "THE_COUNT_SOURCE_HOST": "localhost", + "THE_COUNT_SOURCE_USERNAME": "admin", + "THE_COUNT_SOURCE_PASSWORD": "pass", +} + @pytest.fixture(name="valid_config_file") def valid_config_file_fn(tmp_path): - """ mocks a valid config file thecount.conf""" + """mocks a valid config file thecount.conf""" path = tmp_path / "t1.conf" - path.write_text( - "[source]\nconnection = mysql+pymysql://dbuser:pass@exampledb:3306\n" - ) + path.write_text("[source]\nport = 3306\n") return path -def test_reads_connection_string(valid_config_file): - """ test that connection string is read correctly from config file """ +def test_reads_connection_string(monkeypatch, valid_config_file): + """test that connection string is read correctly from config file""" + + for env_var, value in MOCK_SOURCE_ENV_VARS.items(): + monkeypatch.setenv(env_var, value) + assert ( Source(str(valid_config_file)).conn_string - == "mysql+pymysql://dbuser:pass@exampledb:3306" + == "mysql+pymysql://admin:pass@localhost:3306" ) @@ -50,8 +58,25 @@ def test_missing_key_raises(tmp_path): Source(str(path)) -def test_fetch_outside_with_block_raises(valid_config_file): +@pytest.mark.parametrize("omitted_env_var", MOCK_SOURCE_ENV_VARS.keys()) +def test_env_var_missing_raises(monkeypatch, omitted_env_var, valid_config_file): + """tests that omitting an required environment variable raises error""" + for env_var, val in MOCK_SOURCE_ENV_VARS.items(): + monkeypatch.setenv(env_var, val) + + # test that omitting env_var raises + monkeypatch.setenv(omitted_env_var, "") + + with pytest.raises(ValueError, match=omitted_env_var): + Source(str(valid_config_file)) + + +def test_fetch_outside_with_block_raises(monkeypatch, valid_config_file): """tests that error is raised when instantiating Source outside a with block""" + + for env_var, value in MOCK_SOURCE_ENV_VARS.items(): + monkeypatch.setenv(env_var, value) + with pytest.raises(RuntimeError): Source(str(valid_config_file)).fetch("nova", START, END) @@ -59,10 +84,13 @@ def test_fetch_outside_with_block_raises(valid_config_file): @patch("thecount.source.Session") @patch("thecount.source.sqlalchemy.create_engine") def test_fetch_returns_rows_as_dicts( - mock_create_engine, mock_session_cls, valid_config_file + mock_create_engine, mock_session_cls, monkeypatch, valid_config_file ): """test fetch calls database create_engine correctly""" + for env_var, value in MOCK_SOURCE_ENV_VARS.items(): + monkeypatch.setenv(env_var, value) + mock_session = mock_session_cls.return_value.__enter__.return_value mock_session.execute.return_value.mappings.return_value = [ {"foo": 1, "bar": "alpha"}, @@ -73,7 +101,7 @@ def test_fetch_returns_rows_as_dicts( result = source.fetch("nova", START, END) mock_create_engine.assert_called_once_with( - "mysql+pymysql://dbuser:pass@exampledb:3306/nova", + "mysql+pymysql://admin:pass@localhost:3306/nova", pool_pre_ping=True, pool_recycle=3600, pool_size=1, diff --git a/OpenStack-accounting/thecount.conf b/OpenStack-accounting/thecount.conf new file mode 100644 index 00000000..cc4ab6fa --- /dev/null +++ b/OpenStack-accounting/thecount.conf @@ -0,0 +1,8 @@ +[source] +port = 3306 + +[sink] +database = prod +port = 8086 +instance = PreProd + diff --git a/OpenStack-accounting/thecount.conf.example b/OpenStack-accounting/thecount.conf.example deleted file mode 100644 index 51e0882c..00000000 --- a/OpenStack-accounting/thecount.conf.example +++ /dev/null @@ -1,10 +0,0 @@ -[source] -connection =mysql+pymysql://:@:3306 - -[sink] -# auth for influxdb -password=admin -username=admin -database=prod -host=localhost:8086 -instance=PreProd \ No newline at end of file diff --git a/OpenStack-accounting/thecount/__init__.py b/OpenStack-accounting/thecount/__init__.py index 78a161f1..376421b5 100644 --- a/OpenStack-accounting/thecount/__init__.py +++ b/OpenStack-accounting/thecount/__init__.py @@ -24,7 +24,9 @@ # Log to file if not os.path.exists(LOG_FP): - logger.warning("log filepath %s does not exist, logs aren't being stored...", LOG_FP) + logger.warning( + "log filepath %s does not exist, logs aren't being stored...", LOG_FP + ) else: file_handler = logging.FileHandler(LOG_FP) file_handler.setLevel(log_level) diff --git a/OpenStack-accounting/thecount/__main__.py b/OpenStack-accounting/thecount/__main__.py index deb89350..7b1ded23 100644 --- a/OpenStack-accounting/thecount/__main__.py +++ b/OpenStack-accounting/thecount/__main__.py @@ -31,7 +31,7 @@ def run_jobs(details: RunDetails) -> None: "Starting Loop: %s: interval_start_time: %s, interval_end_time: %s", step_counter, current_time, - interval_end + interval_end, ) # Don't start the process until end of the current interval - so all data is available @@ -56,7 +56,7 @@ def run_jobs(details: RunDetails) -> None: def main() -> int: - """ main function - sets up parser, parses args and calls run_jobs() """ + """main function - sets up parser, parses args and calls run_jobs()""" parser = setup_parser() args = parser.parse_args() diff --git a/OpenStack-accounting/thecount/sink.py b/OpenStack-accounting/thecount/sink.py index 23a54672..3c2f4e86 100644 --- a/OpenStack-accounting/thecount/sink.py +++ b/OpenStack-accounting/thecount/sink.py @@ -1,4 +1,5 @@ import logging +import os from configparser import ConfigParser from typing import Optional @@ -22,17 +23,30 @@ def _load_config(self, config_fp: str) -> None: helper function to load in thecount config file and extract sink-related config info :param config_fp: path to thecount config file """ + + def require_env(name: str) -> str: + """ + helper to load in environment variables + :param name: environment variable name + :return: environment variable value + """ + value = os.getenv(name) + if not value: + raise ValueError(f"{name}: influxdb environment variable not set") + return value + parser = ConfigParser(interpolation=None) if not parser.read(config_fp, encoding="utf-8"): raise FileNotFoundError(f"config not found: {config_fp}") - host = parser.get("sink", "host") - self._host, _, port = host.partition(":") - self._port = int(port) if port else DEFAULT_PORT - self._database = parser.get("sink", "database") self.instance = parser.get("sink", "instance") - self._username = parser.get("sink", "username") - self._password = parser.get("sink", "password") + + self._port = parser.get("sink", "port") + self._database = parser.get("sink", "database") + + self._host = require_env("THE_COUNT_SINK_HOST") + self._username = require_env("THE_COUNT_SINK_USERNAME") + self._password = require_env("THE_COUNT_SINK_PASSWORD") def __enter__(self) -> "Sink": """ diff --git a/OpenStack-accounting/thecount/source.py b/OpenStack-accounting/thecount/source.py index f79e7400..96f52587 100644 --- a/OpenStack-accounting/thecount/source.py +++ b/OpenStack-accounting/thecount/source.py @@ -1,5 +1,6 @@ import configparser import logging +import os from datetime import datetime from typing import Optional, Dict @@ -26,15 +27,38 @@ def __init__(self, config_fp: str): def _load_config(self, config_fp: str) -> None: """ - Helper function to read thecount config file and extract source-related config - constructs a connection string. Done in __init__ so issues with the config - path fails at startup rather than on the first window. + Helper function to read in thecount config file and pre-set environment variables + to setup a Source object. + + Constructs a connection string to connect to source DB. + + Done in __init__ so issues with the config path and environment variables fails at startup + rather than on the first window + :param config_fp: path to config file """ + + def require_env(name: str) -> str: + """ + helper to load in environment variables + :param name: environment variable name + :return: environment variable value + """ + value = os.getenv(name) + if not value: + raise ValueError(f"{name}: openstack db environment variable not set") + return value + parser = configparser.ConfigParser() if not parser.read(config_fp, encoding="utf-8"): raise FileNotFoundError(f"config not found: {config_fp}") - self.conn_string = parser.get("source", "connection") + port = parser.getint("source", "port") + + username = require_env("THE_COUNT_SOURCE_USERNAME") + password = require_env("THE_COUNT_SOURCE_PASSWORD") + host = require_env("THE_COUNT_SOURCE_HOST") + + self.conn_string = f"mysql+pymysql://{username}:{password}@{host}:{port}" def __enter__(self) -> "Source": """ From 8cba3f0fa5df16e5f453c8d9317362ab7fce07ea Mon Sep 17 00:00:00 2001 From: AnishMudaraddi Date: Wed, 2 Sep 2026 16:19:37 +0100 Subject: [PATCH 08/11] set time interval properly to 24 hours --- OpenStack-accounting/tests/test_cli.py | 16 ++++++++-------- OpenStack-accounting/thecount/cli.py | 14 +++++--------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/OpenStack-accounting/tests/test_cli.py b/OpenStack-accounting/tests/test_cli.py index 692b0f68..b4500a55 100644 --- a/OpenStack-accounting/tests/test_cli.py +++ b/OpenStack-accounting/tests/test_cli.py @@ -15,7 +15,7 @@ def make_args(**overrides) -> argparse.Namespace: # set config-path env variable defaults = { - "jobs": None, + "job": None, "all": False, "start_time": None, "end_time": None, @@ -92,13 +92,13 @@ def test_all_selects_every_job(): def test_single_job(): """tests that single job object is created for valid job identifier""" - cfg = build_config(make_args(jobs=["cinder"])) + cfg = build_config(make_args(job=["cinder"])) assert isinstance(cfg.jobs[0], ALL_JOBS["cinder"]) def test_job_values_split(): """tests that providing multiple jobs works properly""" - cfg = build_config(make_args(jobs=["cinder", "nova"])) + cfg = build_config(make_args(job=["cinder", "nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] assert job_cls == set(expected_cls) @@ -106,7 +106,7 @@ def test_job_values_split(): def test_job_values_no_whitespace_tolerated(): """test that --job comma-spaced values with no whitespace is parsed correctly""" - cfg = build_config(make_args(jobs=["cinder,nova"])) + cfg = build_config(make_args(job=["cinder,nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] assert job_cls == set(expected_cls) @@ -114,7 +114,7 @@ def test_job_values_no_whitespace_tolerated(): def test_job_values_whitespace_tolerated(): """test that --job comma-spaced values with whitespace is parsed properly""" - cfg = build_config(make_args(jobs=["cinder,nova", "glance"])) + cfg = build_config(make_args(job=["cinder,nova", "glance"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"], ALL_JOBS["glance"]] assert job_cls == set(expected_cls) @@ -122,7 +122,7 @@ def test_job_values_whitespace_tolerated(): def test_job_values_empty_vals_ignored(): """test that multiple commas are handled correctly""" - cfg = build_config(make_args(jobs=["cinder,,nova"])) + cfg = build_config(make_args(job=["cinder,,nova"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] assert job_cls == set(expected_cls) @@ -130,7 +130,7 @@ def test_job_values_empty_vals_ignored(): def test_duplicates_run_once(): """test that duplicate jobs are removed and only set to run once""" - cfg = build_config(make_args(jobs=["cinder", "nova,cinder"])) + cfg = build_config(make_args(job=["cinder", "nova,cinder"])) job_cls = set(type(job) for job in cfg.jobs) expected_cls = [ALL_JOBS["cinder"], ALL_JOBS["nova"]] assert job_cls == set(expected_cls) @@ -139,7 +139,7 @@ def test_duplicates_run_once(): def test_unknown_job_named_in_error(): """test that unknown job names raise an error""" with pytest.raises(ValueError, match="foo"): - build_config(make_args(jobs=["cinder,foo"])) + build_config(make_args(job=["cinder,foo"])) def test_start_defaults_to_current_date_midnight(): diff --git a/OpenStack-accounting/thecount/cli.py b/OpenStack-accounting/thecount/cli.py index 193208bc..70d9be6d 100644 --- a/OpenStack-accounting/thecount/cli.py +++ b/OpenStack-accounting/thecount/cli.py @@ -24,10 +24,6 @@ "manila": ManilaParser, } -# set constant interval as 1440 seconds - 1 day -INTERVAL = 1440 - - def setup_parser() -> argparse.ArgumentParser: """ setup parser rules, to read in and validate command line arguments @@ -121,15 +117,15 @@ def build_config(args: argparse.Namespace) -> RunDetails: :returns: a RunDetails struct containing parameters on what accounting jobs to run and how to run them """ - if args.all and args.jobs: + if args.all and args.job: raise ValueError("--all cannot be used with --job") # defaults to --all if neither given - i.e. run all jobs in list names = list(ALL_JOBS) - # if --jobs specified, parse input and validate - if args.jobs: - names = _split_jobs(args.jobs) + # if --job specified, parse input and validate + if args.job: + names = _split_jobs(args.job) unknown = [name for name in names if name not in ALL_JOBS] if unknown: raise ValueError( @@ -168,7 +164,7 @@ def build_config(args: argparse.Namespace) -> RunDetails: sink=Sink(args.config_path), source=Source(args.config_path), dry_run=args.dry_run, - interval=timedelta(seconds=INTERVAL), + interval=timedelta(hours=24), start_time=start, end_time=end, ) From c095237e8b997483f84e3defe73460c4cdb2ef9a Mon Sep 17 00:00:00 2001 From: AnishMudaraddi Date: Wed, 2 Sep 2026 17:24:36 +0100 Subject: [PATCH 09/11] move run_jobs function into its own module so that logging works - doesn't seem to register the logger if not --- OpenStack-accounting/README.md | 2 +- OpenStack-accounting/pyproject.toml | 2 +- .../tests/{test_main.py => test_run_jobs.py} | 4 +- OpenStack-accounting/thecount/__main__.py | 54 +------------------ OpenStack-accounting/thecount/run_jobs.py | 54 +++++++++++++++++++ 5 files changed, 59 insertions(+), 57 deletions(-) rename OpenStack-accounting/tests/{test_main.py => test_run_jobs.py} (98%) create mode 100644 OpenStack-accounting/thecount/run_jobs.py diff --git a/OpenStack-accounting/README.md b/OpenStack-accounting/README.md index 60bf5aa1..27e1978e 100644 --- a/OpenStack-accounting/README.md +++ b/OpenStack-accounting/README.md @@ -73,7 +73,7 @@ and populate with creds ### As a container ```commandline -docker build . -t harbor.stfc.ac.uk/stfc-cloud/thecount:v1.0.0 -t harbor.stfc.ac.uk/stfc-cloud/thecount:latest +docker build . -t harbor.stfc.ac.uk/stfc-cloud/thecount:v1.1.1 -t harbor.stfc.ac.uk/stfc-cloud/thecount:latest docker run \ -v thecount.conf:/etc/thecount/thecount.conf \ -v var/log/thecount/:/var/log/thecount/ \ diff --git a/OpenStack-accounting/pyproject.toml b/OpenStack-accounting/pyproject.toml index 32cc254f..b7f2587f 100644 --- a/OpenStack-accounting/pyproject.toml +++ b/OpenStack-accounting/pyproject.toml @@ -7,7 +7,7 @@ where = ["."] [project] name = "thecount" -version = "1.1.0" +version = "1.1.1" description = "python package for collecting stfc cloud accounting data" authors = [{name = "STFC Cloud Team", email = "cloud-support@stfc.ac.uk"}] readme = "README.md" diff --git a/OpenStack-accounting/tests/test_main.py b/OpenStack-accounting/tests/test_run_jobs.py similarity index 98% rename from OpenStack-accounting/tests/test_main.py rename to OpenStack-accounting/tests/test_run_jobs.py index 792673ab..d3fda185 100644 --- a/OpenStack-accounting/tests/test_main.py +++ b/OpenStack-accounting/tests/test_run_jobs.py @@ -5,7 +5,7 @@ import pytest from thecount.parsers.base_parser import BaseParser -from thecount.__main__ import run_jobs +from thecount.run_jobs import run_jobs from thecount.source import Source from thecount.sink import Sink from thecount.structs import RunDetails @@ -80,7 +80,7 @@ def setup_run_details( @pytest.fixture(name="stub_sleep", autouse=True) def stub_sleep_fn(): """stubs out sleep for testing""" - with patch("thecount.__main__.time.sleep") as sleep: + with patch("thecount.run_jobs.time.sleep") as sleep: yield sleep diff --git a/OpenStack-accounting/thecount/__main__.py b/OpenStack-accounting/thecount/__main__.py index 7b1ded23..9d800475 100644 --- a/OpenStack-accounting/thecount/__main__.py +++ b/OpenStack-accounting/thecount/__main__.py @@ -1,59 +1,7 @@ import sys -import time -import logging -from datetime import datetime from thecount.cli import setup_parser, build_config -from thecount.structs import RunDetails - -logger = logging.getLogger(__name__) - - -def run_jobs(details: RunDetails) -> None: - """ - function that runs jobs continuously at regular intervals until a specified end_time param reached. - Sleeps between intervals. - :param details: RunDetails object - containing parameters on what jobs to run and for how long - """ - with details.source, details.sink: - step_counter = 0 - current_time = details.start_time - while True: - step_counter += 1 - interval_end = current_time + details.interval - - # If there isn't a complete interval before end_time, stop. - if details.end_time is not None and interval_end > details.end_time: - logger.info("%s reached. Stopping...", details.end_time) - break - - logger.info( - "Starting Loop: %s: interval_start_time: %s, interval_end_time: %s", - step_counter, - current_time, - interval_end, - ) - - # Don't start the process until end of the current interval - so all data is available - now = datetime.now(current_time.tzinfo) - if interval_end > now: - logger.info("waiting until %s...", interval_end.isoformat()) - time.sleep((interval_end - now).total_seconds()) - - # Run every job for this interval. - for job in details.jobs: - logger.info("running job: %s, dry_run=%s", type(job), details.dry_run) - job.run( - source=details.source, - sink=details.sink, - start_time=current_time, - end_time=interval_end, - dry_run=details.dry_run, - ) - - # Move to the next interval. - current_time = interval_end - +from thecount.run_jobs import run_jobs def main() -> int: """main function - sets up parser, parses args and calls run_jobs()""" diff --git a/OpenStack-accounting/thecount/run_jobs.py b/OpenStack-accounting/thecount/run_jobs.py new file mode 100644 index 00000000..ac6ecd1d --- /dev/null +++ b/OpenStack-accounting/thecount/run_jobs.py @@ -0,0 +1,54 @@ +import logging +from datetime import datetime +import time + +from thecount.structs import RunDetails + + +logger = logging.getLogger(__name__) + + +def run_jobs(details: RunDetails) -> None: + """ + function that runs jobs continuously at regular intervals until a specified end_time param reached. + Sleeps between intervals. + :param details: RunDetails object - containing parameters on what jobs to run and for how long + """ + with details.source, details.sink: + step_counter = 0 + current_time = details.start_time + while True: + step_counter += 1 + interval_end = current_time + details.interval + + # If there isn't a complete interval before end_time, stop. + if details.end_time is not None and interval_end > details.end_time: + logger.info("%s reached. Stopping...", details.end_time) + break + + logger.info( + "Starting Loop: %s: interval_start_time: %s, interval_end_time: %s", + step_counter, + current_time, + interval_end, + ) + + # Don't start the process until end of the current interval - so all data is available + now = datetime.now(current_time.tzinfo) + if interval_end > now: + logger.info("waiting until %s...", interval_end.isoformat()) + time.sleep((interval_end - now).total_seconds()) + + # Run every job for this interval. + for job in details.jobs: + logger.info("running job: %s, dry_run=%s", type(job), details.dry_run) + job.run( + source=details.source, + sink=details.sink, + start_time=current_time, + end_time=interval_end, + dry_run=details.dry_run, + ) + + # Move to the next interval. + current_time = interval_end \ No newline at end of file From bb400eb01a83c603ffb015fe0469f304c8fae657 Mon Sep 17 00:00:00 2001 From: anish-mudaraddi Date: Thu, 3 Sep 2026 12:00:47 +0100 Subject: [PATCH 10/11] further pull request changes set default --start-date as yesterday so that parsing is done immediately so we don't have to wait 24hours before we find out something's broken modify the docker build script to do pip install as monitoring user rather than root for added security use verison.txt to set the version of docker container --- OpenStack-accounting/Dockerfile | 33 +++++++++-------------- OpenStack-accounting/README.md | 10 +++++-- OpenStack-accounting/pyproject.toml | 2 +- OpenStack-accounting/tests/test_cli.py | 8 +++--- OpenStack-accounting/thecount/cli.py | 12 ++++++--- OpenStack-accounting/thecount/run_jobs.py | 1 + OpenStack-accounting/uv.lock | 18 +------------ OpenStack-accounting/version.txt | 1 + 8 files changed, 37 insertions(+), 48 deletions(-) create mode 100644 OpenStack-accounting/version.txt diff --git a/OpenStack-accounting/Dockerfile b/OpenStack-accounting/Dockerfile index ac658839..7dee8ce7 100644 --- a/OpenStack-accounting/Dockerfile +++ b/OpenStack-accounting/Dockerfile @@ -1,21 +1,19 @@ # Use Python 3.12 slim image as base FROM python:3.12-slim -WORKDIR /app - # Install system dependencies if needed RUN apt-get update && apt-get install -y \ --no-install-recommends ca-certificates\ && rm -rf /var/lib/apt/lists/* -# Parse and report everything in UTC unless the operator overrides it. -ENV TZ=UTC \ - PYTHONUNBUFFERED=1 \ - THE_COUNT_CONFIG_FILE=/etc/thecount/thecount.conf \ - LOG_LEVEL=INFO \ - LOG_FILE=/var/log/thecount/thecount.log +# Create a non-root user +# Need to create a home directory for pip to create ~/.local or ~/.cache whilst building +RUN useradd --create-home --shell /bin/bash monitoring +USER monitoring +WORKDIR /app # Python dependencies for the extractors. + # Copy package files for installation COPY pyproject.toml README.md ./ COPY thecount/ ./thecount/ @@ -23,19 +21,12 @@ COPY thecount/ ./thecount/ # Install only the package with core dependencies (no optional dependencies) RUN pip install --no-cache-dir . -# Create a non-root user -RUN useradd --shell /bin/bash monitoring -RUN chown -R monitoring:monitoring /app -USER monitoring - -ENV THE_COUNT_SOURCE_USERNAME=NOT_SET \ - THE_COUNT_SOURCE_PASSWORD=NOT_SET \ - THE_COUNT_SOURCE_HOST=NOT_SET \ - - THE_COUNT_SINK_USERNAME=NOT_SET \ - THE_COUNT_SINK_PASSWORD=NOT_SET \ - THE_COUNT_SINK_HOST=NOT_SET - +# Parse and report everything in UTC unless the operator overrides it. +ENV TZ=UTC \ + PYTHONUNBUFFERED=1 \ + THE_COUNT_CONFIG_FILE=/etc/thecount/thecount.conf \ + LOG_LEVEL=INFO \ + LOG_FILE=/var/log/thecount/thecount.log # Set the entrypoint to your package's main module ENTRYPOINT ["python", "-m", "thecount"] diff --git a/OpenStack-accounting/README.md b/OpenStack-accounting/README.md index 27e1978e..b94436a1 100644 --- a/OpenStack-accounting/README.md +++ b/OpenStack-accounting/README.md @@ -73,11 +73,17 @@ and populate with creds ### As a container ```commandline +<<<<<<< Updated upstream docker build . -t harbor.stfc.ac.uk/stfc-cloud/thecount:v1.1.1 -t harbor.stfc.ac.uk/stfc-cloud/thecount:latest +======= +export VERSION=$(cat version.txt) +docker build . -t harbor.stfc.ac.uk/stfc-cloud/thecount:${VERSION} -t harbor.stfc.ac.uk/stfc-cloud/thecount:latest + +>>>>>>> Stashed changes docker run \ -v thecount.conf:/etc/thecount/thecount.conf \ -v var/log/thecount/:/var/log/thecount/ \ - harbor.stfc.ac.uk/stfc-cloud/thecount:latest --help + harbor.stfc.ac.uk/stfc-cloud/thecount:${VERSION} --help ``` on kayobe to get previous data @@ -86,7 +92,7 @@ docker run \ --rm \ --env-file ~/opt/kayobe/accounting-service/.env \ -v /opt/kayobe/accounting-service/:/etc/thecount/ \ - --network host harbor.stfc.ac.uk/stfc-cloud/thecount:v1.0.0 \ + --network host harbor.stfc.ac.uk/stfc-cloud/thecount:${VERSION} \ --config-path /etc/thecount/thecount.conf \ --start-time --end-time ``` diff --git a/OpenStack-accounting/pyproject.toml b/OpenStack-accounting/pyproject.toml index b7f2587f..e4ec5bec 100644 --- a/OpenStack-accounting/pyproject.toml +++ b/OpenStack-accounting/pyproject.toml @@ -7,7 +7,7 @@ where = ["."] [project] name = "thecount" -version = "1.1.1" +version = "1.2.0" description = "python package for collecting stfc cloud accounting data" authors = [{name = "STFC Cloud Team", email = "cloud-support@stfc.ac.uk"}] readme = "README.md" diff --git a/OpenStack-accounting/tests/test_cli.py b/OpenStack-accounting/tests/test_cli.py index b4500a55..1d1ee34c 100644 --- a/OpenStack-accounting/tests/test_cli.py +++ b/OpenStack-accounting/tests/test_cli.py @@ -1,6 +1,6 @@ import argparse from unittest.mock import patch -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta import pytest from conftest import ROOT_TEST_DATA_FP @@ -142,11 +142,13 @@ def test_unknown_job_named_in_error(): build_config(make_args(job=["cinder,foo"])) -def test_start_defaults_to_current_date_midnight(): +def test_start_defaults_to_yesterday_date_midnight(): """test that start time sets now() time properly""" curr = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + yesterday = curr - timedelta(days=1) cfg = build_config(make_args(all=True)) - assert cfg.start_time == curr + assert cfg.start_time < curr + assert cfg.start_time == yesterday def test_end_defaults_to_none(): diff --git a/OpenStack-accounting/thecount/cli.py b/OpenStack-accounting/thecount/cli.py index 70d9be6d..5383b4f5 100644 --- a/OpenStack-accounting/thecount/cli.py +++ b/OpenStack-accounting/thecount/cli.py @@ -16,6 +16,9 @@ logger = logging.getLogger(__name__) +# hardcoded interval +INTERVAL_HOURS = timedelta(hours=24) + # parse jobs ALL_JOBS: dict[str, type[BaseParser]] = { "cinder": CinderParser, @@ -54,7 +57,7 @@ def setup_parser() -> argparse.ArgumentParser: metavar="TIME", default=None, help="(optional) start of the range in format: yyyy-mm-dd" - "if not given, --start-time will be set as the current date", + "if not given, --start-time will be set as midnight the day before current date", ) parser.add_argument( "--end-time", @@ -149,8 +152,9 @@ def build_config(args: argparse.Namespace) -> RunDetails: raise ValueError(f"unable to parse --start-time: {e}") from e else: today = datetime.now(timezone.utc).date() - start = datetime(today.year, today.month, today.day, tzinfo=timezone.utc) - logger.info("--start-time set to %s", start) + curr = datetime(today.year, today.month, today.day, tzinfo=timezone.utc) + start = curr - timedelta(days=1) + logger.info("--start-time set to %s (yesterday)", start) # if --end-time given, make sure it is after --start-time (or current date if not given) if end and start >= end: @@ -164,7 +168,7 @@ def build_config(args: argparse.Namespace) -> RunDetails: sink=Sink(args.config_path), source=Source(args.config_path), dry_run=args.dry_run, - interval=timedelta(hours=24), + interval=INTERVAL_HOURS, start_time=start, end_time=end, ) diff --git a/OpenStack-accounting/thecount/run_jobs.py b/OpenStack-accounting/thecount/run_jobs.py index ac6ecd1d..fcd0a3dd 100644 --- a/OpenStack-accounting/thecount/run_jobs.py +++ b/OpenStack-accounting/thecount/run_jobs.py @@ -17,6 +17,7 @@ def run_jobs(details: RunDetails) -> None: with details.source, details.sink: step_counter = 0 current_time = details.start_time + while True: step_counter += 1 interval_end = current_time + details.interval diff --git a/OpenStack-accounting/uv.lock b/OpenStack-accounting/uv.lock index d5b844f5..81ef49b4 100644 --- a/OpenStack-accounting/uv.lock +++ b/OpenStack-accounting/uv.lock @@ -343,18 +343,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072, upload-time = "2026-07-22T11:38:14.299Z" }, { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393, upload-time = "2026-07-22T12:26:37.964Z" }, { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750, upload-time = "2026-07-22T12:28:59.546Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5c/53d6b94742a6f1ee1877c7ff76262c909e137f3f7383ce96a8ab78e1ae31/greenlet-3.5.4-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52", size = 629659, upload-time = "2026-07-22T12:43:40.451Z" }, { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920, upload-time = "2026-07-22T11:51:06.83Z" }, - { url = "https://files.pythonhosted.org/packages/f2/34/957fc5577180ef2f57be82580ee1f59fdefad4f6c623c7d5e1b6980a76fb/greenlet-3.5.4-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c", size = 425580, upload-time = "2026-07-22T12:39:48.326Z" }, { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262, upload-time = "2026-07-22T12:25:01.131Z" }, { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906, upload-time = "2026-07-22T11:51:08.627Z" }, { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036, upload-time = "2026-07-22T11:38:30.073Z" }, { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, - { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, @@ -362,9 +358,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, - { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, @@ -372,9 +366,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, - { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, @@ -382,9 +374,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, - { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, @@ -392,18 +382,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, - { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, - { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, - { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, @@ -411,9 +397,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, - { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, - { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, @@ -822,7 +806,7 @@ wheels = [ [[package]] name = "thecount" -version = "1.0.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "influxdb" }, diff --git a/OpenStack-accounting/version.txt b/OpenStack-accounting/version.txt new file mode 100644 index 00000000..0408c30b --- /dev/null +++ b/OpenStack-accounting/version.txt @@ -0,0 +1 @@ +v1.2.0 \ No newline at end of file From b058da86678bf36b263af7421d4342596a08abd5 Mon Sep 17 00:00:00 2001 From: anish-mudaraddi Date: Mon, 7 Sep 2026 15:44:27 +0100 Subject: [PATCH 11/11] set verify tls cert to True changed influx2 to use verified TLS certificates from Sectigo --- OpenStack-accounting/tests/test_sink.py | 2 +- OpenStack-accounting/thecount/sink.py | 2 +- OpenStack-accounting/version.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/OpenStack-accounting/tests/test_sink.py b/OpenStack-accounting/tests/test_sink.py index 87ebfd64..9780c49c 100644 --- a/OpenStack-accounting/tests/test_sink.py +++ b/OpenStack-accounting/tests/test_sink.py @@ -85,7 +85,7 @@ def test_write_success(mock_influxdb_client, monkeypatch, valid_config_file): password="pass", database="accounting", ssl=True, - verify_ssl=False, + verify_ssl=True, timeout=TIMEOUT, ) diff --git a/OpenStack-accounting/thecount/sink.py b/OpenStack-accounting/thecount/sink.py index 3c2f4e86..0003cbe8 100644 --- a/OpenStack-accounting/thecount/sink.py +++ b/OpenStack-accounting/thecount/sink.py @@ -61,7 +61,7 @@ def __enter__(self) -> "Sink": password=self._password, database=self._database, ssl=True, - verify_ssl=False, + verify_ssl=True, timeout=TIMEOUT, ) logger.debug( diff --git a/OpenStack-accounting/version.txt b/OpenStack-accounting/version.txt index 0408c30b..24e56e03 100644 --- a/OpenStack-accounting/version.txt +++ b/OpenStack-accounting/version.txt @@ -1 +1 @@ -v1.2.0 \ No newline at end of file +v1.2.1 \ No newline at end of file