From ec907bb4e0b21778dd4ad6c2740f21a4d3f91cce Mon Sep 17 00:00:00 2001 From: h7x4 Date: Fri, 12 Dec 2025 19:44:11 +0900 Subject: [PATCH] Add benchmarks --- .gitea/workflows/benchmark.yaml | 72 +++++ .gitea/workflows/test.yaml | 18 +- .gitignore | 2 + nix/shell.nix | 2 + pyproject.toml | 20 ++ tests/benchmark/__init__.py | 0 tests/benchmark/benchmark_settings.py | 11 + tests/benchmark/helpers.py | 299 ++++++++++++++++++ .../test_benchmark_product_owners.py | 47 +++ .../benchmark/test_benchmark_product_price.py | 44 +++ .../benchmark/test_benchmark_product_stock.py | 47 +++ .../test_benchmark_transaction_log.py | 47 +++ .../benchmark/test_benchmark_user_balance.py | 39 +++ uv.lock | 74 +++++ 14 files changed, 706 insertions(+), 16 deletions(-) create mode 100644 .gitea/workflows/benchmark.yaml create mode 100644 tests/benchmark/__init__.py create mode 100644 tests/benchmark/benchmark_settings.py create mode 100644 tests/benchmark/helpers.py create mode 100644 tests/benchmark/test_benchmark_product_owners.py create mode 100644 tests/benchmark/test_benchmark_product_price.py create mode 100644 tests/benchmark/test_benchmark_product_stock.py create mode 100644 tests/benchmark/test_benchmark_transaction_log.py create mode 100644 tests/benchmark/test_benchmark_user_balance.py diff --git a/.gitea/workflows/benchmark.yaml b/.gitea/workflows/benchmark.yaml new file mode 100644 index 0000000..6f3b577 --- /dev/null +++ b/.gitea/workflows/benchmark.yaml @@ -0,0 +1,72 @@ +name: Run benchmarks +on: + workflow_dispatch: + # TODO: make this only workflow_dispatch when merged into main + push: + +jobs: + run-tests: + runs-on: debian-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --locked --group test + + - name: Run benchmarks + continue-on-error: true + run: | + set -euo pipefail + set -x + + PYTEST_ARGS=( + -vv + + --benchmark-only + -k test_benchmark + ) + + uv run -- pytest "${PYTEST_ARGS[@]}" + + - name: Upload benchmark JSON report + uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2 + with: + source: ./benchmark/*/*.json + quote-source: false + target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json + username: gitea-web + ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }} + host: pages.pvv.ntnu.no + known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg" + + - name: Upload histograms + uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2 + with: + source: ./benchmark/*.svg + quote-source: false + target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/ + username: gitea-web + ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }} + host: pages.pvv.ntnu.no + known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg" + + # NOTE: $GITHUB_STEP_SUMMARY when... + - name: Run information + run: | + echo "Benchmark run ID: ${{ github.run_id }}" + echo "Benchmark JSON: https://pages.pvv.ntnu.no/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json" + echo "Histograms: https://pages.pvv.ntnu.no/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_owners.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_price.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_stock.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-transaction_log.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-user_balance.svg" + + - name: Check failure + if: failure() + run: | + echo "Tests failed" + exit 1 diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index fa9629f..c73e531 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -30,22 +30,8 @@ jobs: set -euo pipefail set -x - PYTEST_ARGS=( - -vv - - --cov=dibbler.lib - --cov=dibbler.models - --cov=dibbler.queries - --cov-report=html - --cov-branch - - --self-contained-html - --html=./test-report/index.html - ) - - if [ "$DEBUG_SQL" == "true" ]; then - PYTEST_ARGS+=( - --debug-sql + PYTEST_ARGS=( + -vv ) if [ "$DEBUG_SQL" == "true" ]; then diff --git a/.gitignore b/.gitignore index 2aaeea2..8ab0dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,7 @@ test.db .ruff_cache .coverage +.coverage.* htmlcov test-report +/benchmark diff --git a/nix/shell.nix b/nix/shell.nix index adb6302..bad55df 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -20,6 +20,8 @@ mkShell { pytest pytest-cov pytest-html + pytest-benchmark + pygal ])) ]; } diff --git a/pyproject.toml b/pyproject.toml index 89b2db5..d0ac2ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ test = [ "coverage-badge>=1.1.2", "pytest-html>=4.1.1", "sqlparse>=0.5.4", + "pytest-benchmark[histogram]>=5.2.3", ] [tool.setuptools.packages.find] @@ -40,3 +41,22 @@ line-length = 100 [tool.ruff] line-length = 100 + +[tool.pytest.ini_options] +addopts = [ + "--cov=dibbler.lib", + "--cov=dibbler.models", + "--cov=dibbler.queries", + "--cov-report=html", + "--cov-branch", + + "--self-contained-html", + "--html=./test-report/index.html", + + "--benchmark-skip", + "--benchmark-autosave", + "--benchmark-save=default", + "--benchmark-verbose", + "--benchmark-storage=benchmark", + "--benchmark-histogram=benchmark/histogram", +] diff --git a/tests/benchmark/__init__.py b/tests/benchmark/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/benchmark/benchmark_settings.py b/tests/benchmark/benchmark_settings.py new file mode 100644 index 0000000..ca13694 --- /dev/null +++ b/tests/benchmark/benchmark_settings.py @@ -0,0 +1,11 @@ +TRANSACTION_GENERATOR_EXCEPTION_LIMIT = 15 +""" +The random transaction generator uses a set seed to generate transactions. +However, not all transactions are valid in all contexts. We catch illegal +generated transactions with a try/except, and retry until we generate a valid +one. However, if we exceed this limit, something is likely wrong with the generator +instead, due to the unlikely high number of exceptions. +""" + +BENCHMARK_ITERATIONS = 5 +BENCHMARK_ROUNDS = 3 diff --git a/tests/benchmark/helpers.py b/tests/benchmark/helpers.py new file mode 100644 index 0000000..e3250d6 --- /dev/null +++ b/tests/benchmark/helpers.py @@ -0,0 +1,299 @@ +import random +from datetime import datetime, timedelta + +from sqlalchemy.orm import Session + +from dibbler.models import Product, Transaction, TransactionType, User +from dibbler.queries import joint_buy_product +from tests.benchmark.benchmark_settings import TRANSACTION_GENERATOR_EXCEPTION_LIMIT + + +def insert_users_and_products( + sql_session: Session, user_count: int = 10, product_count: int = 10 +) -> tuple[list[User], list[Product]]: + users = [] + for i in range(user_count): + user = User(f"User{i + 1}") + sql_session.add(user) + users.append(user) + sql_session.commit() + + products = [] + for i in range(product_count): + barcode = str(1000000000000 + i) + product = Product(barcode, f"Product{i + 1}") + sql_session.add(product) + products.append(product) + sql_session.commit() + + return users, products + + +def generate_random_transactions( + sql_session: Session, + n: int, + seed: int = 42, + transaction_type_filter: list[TransactionType] | None = None, + distribution: dict[TransactionType, float] | None = None, +) -> list[Transaction]: + random.seed(seed) + + if transaction_type_filter is None: + transaction_type_filter = list(TransactionType) + + if TransactionType.JOINT_BUY_PRODUCT in transaction_type_filter: + transaction_type_filter.remove(TransactionType.JOINT_BUY_PRODUCT) + + # TODO: implement me + if TransactionType.THROW_PRODUCT in transaction_type_filter: + transaction_type_filter.remove(TransactionType.THROW_PRODUCT) + + if distribution is None: + distribution = {t: 1 / len(transaction_type_filter) for t in transaction_type_filter} + transaction_types = list(distribution.keys()) + weights = list(distribution.values()) + transactions: list[Transaction] = [] + last_time = datetime(2023, 1, 1, 0, 0, 0) + for _ in range(n): + transaction_type = random.choices(transaction_types, weights=weights, k=1)[0] + generator = RANDOM_GENERATORS[transaction_type] + transaction_or_transactions = generator(sql_session, last_time) + if isinstance(transaction_or_transactions, list): + transactions.extend(transaction_or_transactions) + last_time = max(t.time for t in transaction_or_transactions) + else: + transactions.append(transaction_or_transactions) + last_time = transaction_or_transactions.time + return transactions + + +def random_add_product_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 10) + product_price = random.randint(15, 45) + amount = product_count * product_price + random.randint(-7, 0) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.add_product( + amount, + user.id, + product.id, + product_price, + product_count, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +def random_adjust_balance_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + amount = random.randint(-50, 100) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_balance( + amount, + user.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +def random_adjust_interest_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + amount = random.randint(-5, 5) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_interest( + amount, + user.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +def random_adjust_penalty_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + penalty_multiplier_percent = random.randint(100, 200) + penalty_threshold = random.randint( -150, -50) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_penalty( + penalty_multiplier_percent, + penalty_threshold, + user.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +def random_adjust_stock_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + stock_change = random.randint(-5, 6) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_stock( + user_id=user.id, + product_id=product.id, + product_count=stock_change, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +def random_buy_product_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 5) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.buy_product( + user_id=user.id, + product_id=product.id, + product_count=product_count, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +def random_joint_transaction(sql_session: Session, last_time: datetime) -> list[Transaction]: + i = 0 + while True: + i += 1 + user_count = random.randint(2, 4) + users = random.sample(sql_session.query(User).all(), k=user_count) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 5) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + + try: + transactions = joint_buy_product( + sql_session, + product=product, + product_count=product_count, + instigator=users[0], + users=users, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transactions + + +def random_transfer_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + sender, receiver = random.sample(sql_session.query(User).all(), k=2) + amount = random.randint(1, 50) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.transfer( + amount, + sender.id, + receiver.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +def random_throw_product_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 5) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.throw_product( + user_id=user.id, + product_id=product.id, + product_count=product_count, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed" + ) + continue + return transaction + + +RANDOM_GENERATORS = { + TransactionType.ADD_PRODUCT: random_add_product_transaction, + TransactionType.ADJUST_BALANCE: random_adjust_balance_transaction, + TransactionType.ADJUST_INTEREST: random_adjust_interest_transaction, + TransactionType.ADJUST_PENALTY: random_adjust_penalty_transaction, + TransactionType.ADJUST_STOCK: random_adjust_stock_transaction, + TransactionType.BUY_PRODUCT: random_buy_product_transaction, + TransactionType.JOINT: random_joint_transaction, + TransactionType.TRANSFER: random_transfer_transaction, + TransactionType.THROW_PRODUCT: random_throw_product_transaction, +} diff --git a/tests/benchmark/test_benchmark_product_owners.py b/tests/benchmark/test_benchmark_product_owners.py new file mode 100644 index 0000000..9ef5b6a --- /dev/null +++ b/tests/benchmark/test_benchmark_product_owners.py @@ -0,0 +1,47 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, TransactionType +from dibbler.queries import product_owners +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group='product_owners') +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 2000, + 5000, + 10000, + ], +) +def test_benchmark_product_owners(benchmark, sql_session: Session, transaction_count: int): + _users, products = insert_users_and_products(sql_session) + + generate_random_transactions( + sql_session, + transaction_count, + transaction_type_filter=[ + TransactionType.ADD_PRODUCT, + TransactionType.ADJUST_STOCK, + TransactionType.BUY_PRODUCT, + TransactionType.JOINT, + TransactionType.THROW_PRODUCT, + ], + ) + + benchmark.pedantic( + query_all_product_owners, + args=(sql_session, products), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_product_owners(sql_session: Session, products: list[Product]) -> None: + for product in products: + product_owners(sql_session, product) diff --git a/tests/benchmark/test_benchmark_product_price.py b/tests/benchmark/test_benchmark_product_price.py new file mode 100644 index 0000000..aad31ef --- /dev/null +++ b/tests/benchmark/test_benchmark_product_price.py @@ -0,0 +1,44 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, TransactionType +from dibbler.queries import product_price +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group='product_price') +@pytest.mark.parametrize("transaction_count", [ + 100, + 500, + 1000, + 2000, + 5000, + 10000, +]) +def test_benchmark_product_price(benchmark, sql_session: Session, transaction_count): + _users, products = insert_users_and_products(sql_session) + + generate_random_transactions( + sql_session, + transaction_count, + transaction_type_filter=[ + TransactionType.ADD_PRODUCT, + TransactionType.ADJUST_STOCK, + TransactionType.BUY_PRODUCT, + TransactionType.JOINT, + TransactionType.THROW_PRODUCT, + ], + ) + + benchmark.pedantic( + query_all_products_price, + args=(sql_session, products), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_products_price(sql_session: Session, products: list[Product]) -> None: + for product in products: + product_price(sql_session, product) diff --git a/tests/benchmark/test_benchmark_product_stock.py b/tests/benchmark/test_benchmark_product_stock.py new file mode 100644 index 0000000..a72319c --- /dev/null +++ b/tests/benchmark/test_benchmark_product_stock.py @@ -0,0 +1,47 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, TransactionType +from dibbler.queries import product_stock +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group='product_stock') +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 2000, + 5000, + 10000, + ], +) +def test_benchmark_product_stock(benchmark, sql_session: Session, transaction_count: int): + _users, products = insert_users_and_products(sql_session) + + generate_random_transactions( + sql_session, + transaction_count, + transaction_type_filter=[ + TransactionType.ADD_PRODUCT, + TransactionType.ADJUST_STOCK, + TransactionType.BUY_PRODUCT, + TransactionType.JOINT, + TransactionType.THROW_PRODUCT, + ], + ) + + benchmark.pedantic( + query_all_products_stock, + args=(sql_session, products), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_products_stock(sql_session: Session, products: list[Product]) -> None: + for product in products: + product_stock(sql_session, product) diff --git a/tests/benchmark/test_benchmark_transaction_log.py b/tests/benchmark/test_benchmark_transaction_log.py new file mode 100644 index 0000000..a467049 --- /dev/null +++ b/tests/benchmark/test_benchmark_transaction_log.py @@ -0,0 +1,47 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, User +from dibbler.queries import transaction_log +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group='transaction_log') +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 2000, + 5000, + 10000, + ], +) +def test_benchmark_transaction_log(benchmark, sql_session: Session, transaction_count: int): + users, products = insert_users_and_products(sql_session) + + generate_random_transactions( + sql_session, + transaction_count, + ) + + benchmark.pedantic( + query_transaction_log, + args=( + sql_session, + products, + users, + ), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_transaction_log(sql_session: Session, products: list[Product], users: list[User]) -> None: + for user in users: + transaction_log(sql_session, user=user) + + for product in products: + transaction_log(sql_session, product=product) diff --git a/tests/benchmark/test_benchmark_user_balance.py b/tests/benchmark/test_benchmark_user_balance.py new file mode 100644 index 0000000..a89ec96 --- /dev/null +++ b/tests/benchmark/test_benchmark_user_balance.py @@ -0,0 +1,39 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import User +from dibbler.queries import user_balance +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group='user_balance') +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 1500, + 2000, + ], +) +def test_benchmark_user_balance(benchmark, sql_session: Session, transaction_count: int): + users, _products = insert_users_and_products(sql_session) + + generate_random_transactions( + sql_session, + transaction_count, + ) + + benchmark.pedantic( + query_all_users_balance, + args=(sql_session, users), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_users_balance(sql_session: Session, users: list[User]) -> None: + for user in users: + user_balance(sql_session, user) diff --git a/uv.lock b/uv.lock index ca56931..3aa3b80 100644 --- a/uv.lock +++ b/uv.lock @@ -260,6 +260,7 @@ dependencies = [ test = [ { name = "coverage-badge" }, { name = "pytest" }, + { name = "pytest-benchmark", extra = ["histogram"] }, { name = "pytest-cov" }, { name = "pytest-html" }, { name = "sqlparse" }, @@ -278,6 +279,7 @@ requires-dist = [ test = [ { name = "coverage-badge", specifier = ">=1.1.2" }, { name = "pytest" }, + { name = "pytest-benchmark", extras = ["histogram"], specifier = ">=5.2.3" }, { name = "pytest-cov" }, { name = "pytest-html", specifier = ">=4.1.1" }, { name = "sqlparse", specifier = ">=0.5.4" }, @@ -388,6 +390,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -881,6 +895,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pygal" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b6/04176faeb312c84d7f9bc1a810f96ee38d15597e226bb9bda59f3a5cb122/pygal-3.1.0.tar.gz", hash = "sha256:fbdee7351a7423e7907fb8a9c3b77305f6b5678cb2e6fd0db36a8825e42955ec", size = 81006, upload-time = "2025-12-09T10:29:19.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/4c/2862dd25352fe4b22ec7760d4fa12cb692587cd7ec3e378cbf644fc0d2a8/pygal-3.1.0-py3-none-any.whl", hash = "sha256:4e923490f3490c90c481f4535fa3adcda20ff374257ab9d8ae897f91b632c0bb", size = 130171, upload-time = "2025-12-09T10:29:16.721Z" }, +] + +[[package]] +name = "pygaljs" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/19/3a53f34232a9e6ddad665e71c83693c5db9a31f71785105905c5bc9fbbba/pygaljs-1.0.2.tar.gz", hash = "sha256:0b71ee32495dcba5fbb4a0476ddbba07658ad65f5675e4ad409baf154dec5111", size = 89711, upload-time = "2020-04-03T07:51:44.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/6f/07dab31ca496feda35cf3455b9e9380c43b5c685bb54ad890831c790da38/pygaljs-1.0.2-py2.py3-none-any.whl", hash = "sha256:d75e18cb21cc2cda40c45c3ee690771e5e3d4652bf57206f20137cf475c0dbe8", size = 91111, upload-time = "2020-04-03T07:51:42.658Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -915,6 +959,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + +[package.optional-dependencies] +histogram = [ + { name = "pygal" }, + { name = "pygaljs" }, + { name = "setuptools" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -1013,6 +1077,7 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, @@ -1110,3 +1175,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]