From 961ba1d5c742de753d989276bd9a72a6db45774e Mon Sep 17 00:00:00 2001 From: MuddyHope Date: Thu, 12 Jun 2025 17:51:15 -0700 Subject: [PATCH 1/3] Contribution Page created --- CONTRIBUTING.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d017208 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# 🀝 Contributing to PyQueryTracker + +Welcome! πŸ‘‹ +Thank you for considering a contribution to **PyQueryTracker** β€” a lightweight and extensible query tracking library for Python applications. + +We appreciate all kinds of contributions, from code and documentation to bug reports and feature ideas. This guide will help you get started. + +--- + +## πŸš€ Project Overview + +**PyQueryTracker** provides a Python decorator to track and analyze query performance in web applications. It’s designed to be easy to integrate into FastAPI, support customizable exporters, and work well in production and development environments. + +--- + + +## Assumptions + +1. **You're familiar with [GitHub](https://github.com) and the [Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests)(PR) workflow.** + +## How to Contribute + +1. Make sure that the contribution you want to make is explained or detailed in a GitHub issue! Find an [existing issue](https://github.com/MuddyHope/pyquerytracker/issues/) or [open a new one](https://github.com/MuddyHope/pyquerytracker/issues/new). +2. Once done, [fork the pyquerytracker repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) in your own GitHub account. Ask a maintainer if you want your issue to be checked before making a PR. +3. [Create a new Git branch](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-and-deleting-branches-within-your-repository). +4. Review the necessary checks that describes the steps to maintain the repository. +5. Make the changes on your branch. +6. Submit the branch as a PR](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) pointing to the `main` branch of the main meilisearch-python repository. A maintainer should comment and/or review your Pull Request within a few days. Although depending on the circumstances, it may take longer.
+ +We do not enforce a naming convention for the PRs, but **please use something descriptive of your changes**, having in mind that the title of your PR will be automatically added to the next + + +## 🧠 How You Can Contribute + +Here are some ways you can help: + +- πŸ› Report bugs or suggest improvements via [Issues](https://github.com/MuddyHope/pyquerytracker/issues) +- πŸ“Š Build new exporters (JSON, CSV, Prometheus) +- βš™οΈ Integrate with frameworks like FastAPI, Flask +- πŸ§ͺ Expand test coverage with Pytest +- πŸ“– Improve documentation or add examples +- 🧰 Create a CLI wrapper for runtime stats inspection + +--- + +## πŸ› οΈ Development Setup + +### Clone the repo + +```bash +git clone https://github.com/MuddyHope/pyquerytracker.git +cd pyquerytracker +``` + +### Pushing your changes +```bash +git checkout -b feature/your-feature-name +``` + + +### Running Tests and Linting +```bash +cd pyquerytracker +pip install -e . + +pytest tests/ + +# Linting +black . +isort . + +``` From cff8503604d00684e46a05b776b7fc1443ab5dce Mon Sep 17 00:00:00 2001 From: Luis-Leao-rbcx Date: Fri, 13 Jun 2025 16:30:28 +0100 Subject: [PATCH 2/3] Added a json/csv performance exporter. Also created 2 tests, they are basically the same but the v2 the csv and json files are not deleted so we can inspect them --- pyquerytracker/performance_exporter.py | 52 +++++++++++++++++++ tests/test_performance_exporter.py | 72 ++++++++++++++++++++++++++ tests/test_performance_exporter_v2.py | 71 +++++++++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 pyquerytracker/performance_exporter.py create mode 100644 tests/test_performance_exporter.py create mode 100644 tests/test_performance_exporter_v2.py diff --git a/pyquerytracker/performance_exporter.py b/pyquerytracker/performance_exporter.py new file mode 100644 index 0000000..dc2e02f --- /dev/null +++ b/pyquerytracker/performance_exporter.py @@ -0,0 +1,52 @@ +import json +import csv +import os +import time + +class PerformanceExporter: + def __init__(self, export_path, export_format='json', frequency=None): + """ + :param export_path: The path to export the file. + :param export_format: 'json' or 'csv'. + :param frequency: Optional frequency for writing data (in seconds). + """ + self.export_path = export_path + self.export_format = export_format.lower() + self.frequency = frequency # in seconds + self.last_export_time = None + + if self.export_format not in ['json', 'csv']: + raise ValueError("Unsupported export format. Choose 'json' or 'csv'.") + + def export(self, performance_stats): + """ + Exports performance stats to the configured file if frequency allows it. + """ + current_time = time.time() + + if self.frequency is not None: + if self.last_export_time is not None: + elapsed = current_time - self.last_export_time + if elapsed < self.frequency: + print(f"Skipping export: only {elapsed:.2f}s elapsed, frequency is {self.frequency}s") + return + + os.makedirs(os.path.dirname(self.export_path), exist_ok=True) + + if self.export_format == 'json': + self._export_json(performance_stats) + elif self.export_format == 'csv': + self._export_csv(performance_stats) + + self.last_export_time = current_time + + def _export_json(self, performance_stats): + with open(self.export_path, 'w') as f: + json.dump(performance_stats, f, indent=4) + + def _export_csv(self, performance_stats): + with open(self.export_path, 'w', newline='') as f: + writer = csv.writer(f) + writer.writerow(['Metric', 'Value']) + for key, value in performance_stats.items(): + writer.writerow([key, value]) diff --git a/tests/test_performance_exporter.py b/tests/test_performance_exporter.py new file mode 100644 index 0000000..7d71283 --- /dev/null +++ b/tests/test_performance_exporter.py @@ -0,0 +1,72 @@ +import os +import json +import csv +import pytest +import time + +from pyquerytracker.performance_exporter import PerformanceExporter + +@pytest.mark.parametrize("export_format", ['json', 'csv']) +def test_performance_exporter(tmp_path, export_format): + # Use tests/tmp/ directory for output + export_dir = tmp_path / "tmp" + export_dir.mkdir(parents=True, exist_ok=True) + export_path = export_dir / f"performance.{export_format}" + + performance_stats = { + "cpu_usage": 55.2, + "memory_usage": 2048, + "timestamp": "2025-06-13T12:00:00" + } + + exporter = PerformanceExporter(str(export_path), export_format) + exporter.export(performance_stats) + + assert os.path.exists(export_path) + + if export_format == 'json': + with open(export_path, 'r') as f: + data = json.load(f) + assert data == performance_stats + elif export_format == 'csv': + with open(export_path, 'r', newline='') as f: + reader = csv.reader(f) + rows = list(reader) + assert rows[0] == ['Metric', 'Value'] + exported_data = {row[0]: row[1] for row in rows[1:]} + exported_data = { + k: float(v) if k in ['cpu_usage', 'memory_usage'] else v + for k, v in exported_data.items() + } + assert exported_data["cpu_usage"] == performance_stats["cpu_usage"] + assert exported_data["memory_usage"] == performance_stats["memory_usage"] + assert exported_data["timestamp"] == performance_stats["timestamp"] + +@pytest.mark.parametrize("export_format", ['json', 'csv']) +def test_exporter_frequency(tmp_path, export_format): + export_dir = tmp_path / "tmp" + export_dir.mkdir(parents=True, exist_ok=True) + export_path = export_dir / f"performance.{export_format}" + + performance_stats = { + "cpu_usage": 60.0, + "memory_usage": 4096, + "timestamp": "2025-06-13T12:05:00" + } + + exporter = PerformanceExporter(str(export_path), export_format, frequency=2) + + # First export should always succeed + exporter.export(performance_stats) + first_mtime = os.path.getmtime(export_path) + + # Immediately try to export again β€” should be skipped + exporter.export(performance_stats) + second_mtime = os.path.getmtime(export_path) + assert first_mtime == second_mtime, "File was not modified because of frequency constraint." + + # Wait for frequency to expire + time.sleep(2.1) + exporter.export(performance_stats) + third_mtime = os.path.getmtime(export_path) + assert third_mtime > second_mtime, "File was modified after frequency interval." diff --git a/tests/test_performance_exporter_v2.py b/tests/test_performance_exporter_v2.py new file mode 100644 index 0000000..e362847 --- /dev/null +++ b/tests/test_performance_exporter_v2.py @@ -0,0 +1,71 @@ +import os +import json +import csv +import pytest +import time + +from pyquerytracker.performance_exporter import PerformanceExporter + +# Define persistent output directory +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "output") +os.makedirs(OUTPUT_DIR, exist_ok=True) + +@pytest.mark.parametrize("export_format", ['json', 'csv']) +def test_performance_exporter_persistent(export_format): + export_path = os.path.join(OUTPUT_DIR, f"performance.{export_format}") + + performance_stats = { + "cpu_usage": 55.2, + "memory_usage": 2048, + "timestamp": "2025-06-13T12:00:00" + } + + exporter = PerformanceExporter(export_path, export_format) + exporter.export(performance_stats) + + assert os.path.exists(export_path) + + if export_format == 'json': + with open(export_path, 'r') as f: + data = json.load(f) + assert data == performance_stats + elif export_format == 'csv': + with open(export_path, 'r', newline='') as f: + reader = csv.reader(f) + rows = list(reader) + assert rows[0] == ['Metric', 'Value'] + exported_data = {row[0]: row[1] for row in rows[1:]} + exported_data = { + k: float(v) if k in ['cpu_usage', 'memory_usage'] else v + for k, v in exported_data.items() + } + assert exported_data["cpu_usage"] == performance_stats["cpu_usage"] + assert exported_data["memory_usage"] == performance_stats["memory_usage"] + assert exported_data["timestamp"] == performance_stats["timestamp"] + +@pytest.mark.parametrize("export_format", ['json', 'csv']) +def test_exporter_frequency_persistent(export_format): + export_path = os.path.join(OUTPUT_DIR, f"performance_freq.{export_format}") + + performance_stats = { + "cpu_usage": 60.0, + "memory_usage": 4096, + "timestamp": "2025-06-13T12:05:00" + } + + exporter = PerformanceExporter(export_path, export_format, frequency=2) + + # First export should succeed + exporter.export(performance_stats) + first_mtime = os.path.getmtime(export_path) + + # Immediate second export β€” should be skipped + exporter.export(performance_stats) + second_mtime = os.path.getmtime(export_path) + assert first_mtime == second_mtime, "File should not have been modified due to frequency limit." + + # Wait for frequency period to pass + time.sleep(2.1) + exporter.export(performance_stats) + third_mtime = os.path.getmtime(export_path) + assert third_mtime > second_mtime, "File should be modified after frequency period." From dc010ee80122dc6e8d31ac101124e4788f16c72b Mon Sep 17 00:00:00 2001 From: Luis-Leao-rbcx Date: Mon, 16 Jun 2025 14:15:43 +0100 Subject: [PATCH 3/3] updated the performance exporter the performace exporter will print the function name and the time it takes for it to be executed. --- pyquerytracker/performance_exporter.py | 67 +++++-------------- tests/test_performance_exporter.py | 89 ++++++++------------------ tests/test_performance_exporter_v2.py | 71 -------------------- 3 files changed, 43 insertions(+), 184 deletions(-) delete mode 100644 tests/test_performance_exporter_v2.py diff --git a/pyquerytracker/performance_exporter.py b/pyquerytracker/performance_exporter.py index dc2e02f..4480983 100644 --- a/pyquerytracker/performance_exporter.py +++ b/pyquerytracker/performance_exporter.py @@ -1,52 +1,17 @@ -import json -import csv -import os import time - -class PerformanceExporter: - def __init__(self, export_path, export_format='json', frequency=None): - """ - :param export_path: The path to export the file. - :param export_format: 'json' or 'csv'. - :param frequency: Optional frequency for writing data (in seconds). - """ - self.export_path = export_path - self.export_format = export_format.lower() - self.frequency = frequency # in seconds - self.last_export_time = None - - if self.export_format not in ['json', 'csv']: - raise ValueError("Unsupported export format. Choose 'json' or 'csv'.") - - def export(self, performance_stats): - """ - Exports performance stats to the configured file if frequency allows it. - """ - current_time = time.time() - - if self.frequency is not None: - if self.last_export_time is not None: - elapsed = current_time - self.last_export_time - if elapsed < self.frequency: - print(f"Skipping export: only {elapsed:.2f}s elapsed, frequency is {self.frequency}s") - return - - os.makedirs(os.path.dirname(self.export_path), exist_ok=True) - - if self.export_format == 'json': - self._export_json(performance_stats) - elif self.export_format == 'csv': - self._export_csv(performance_stats) - - self.last_export_time = current_time - - def _export_json(self, performance_stats): - with open(self.export_path, 'w') as f: - json.dump(performance_stats, f, indent=4) - - def _export_csv(self, performance_stats): - with open(self.export_path, 'w', newline='') as f: - writer = csv.writer(f) - writer.writerow(['Metric', 'Value']) - for key, value in performance_stats.items(): - writer.writerow([key, value]) +import functools + +class BuiltinPerformanceExporter: + def __init__(self, output_func=print): + self.output_func = output_func + + def track(self, func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + start = time.perf_counter() + result = func(*args, **kwargs) + end = time.perf_counter() + elapsed_ms = (end - start) * 1000 + self.output_func(f"Function '{func.__name__}' executed in {elapsed_ms:.2f} ms") + return result + return wrapper diff --git a/tests/test_performance_exporter.py b/tests/test_performance_exporter.py index 7d71283..d77fb51 100644 --- a/tests/test_performance_exporter.py +++ b/tests/test_performance_exporter.py @@ -1,72 +1,37 @@ -import os -import json -import csv -import pytest import time +import re +import pytest +from pyquerytracker.performance_exporter import BuiltinPerformanceExporter -from pyquerytracker.performance_exporter import PerformanceExporter - -@pytest.mark.parametrize("export_format", ['json', 'csv']) -def test_performance_exporter(tmp_path, export_format): - # Use tests/tmp/ directory for output - export_dir = tmp_path / "tmp" - export_dir.mkdir(parents=True, exist_ok=True) - export_path = export_dir / f"performance.{export_format}" - - performance_stats = { - "cpu_usage": 55.2, - "memory_usage": 2048, - "timestamp": "2025-06-13T12:00:00" - } - - exporter = PerformanceExporter(str(export_path), export_format) - exporter.export(performance_stats) +def test_track_decorator_outputs_execution_time(capsys): + exporter = BuiltinPerformanceExporter() - assert os.path.exists(export_path) + @exporter.track + def dummy_function(): + time.sleep(0.1) - if export_format == 'json': - with open(export_path, 'r') as f: - data = json.load(f) - assert data == performance_stats - elif export_format == 'csv': - with open(export_path, 'r', newline='') as f: - reader = csv.reader(f) - rows = list(reader) - assert rows[0] == ['Metric', 'Value'] - exported_data = {row[0]: row[1] for row in rows[1:]} - exported_data = { - k: float(v) if k in ['cpu_usage', 'memory_usage'] else v - for k, v in exported_data.items() - } - assert exported_data["cpu_usage"] == performance_stats["cpu_usage"] - assert exported_data["memory_usage"] == performance_stats["memory_usage"] - assert exported_data["timestamp"] == performance_stats["timestamp"] + dummy_function() -@pytest.mark.parametrize("export_format", ['json', 'csv']) -def test_exporter_frequency(tmp_path, export_format): - export_dir = tmp_path / "tmp" - export_dir.mkdir(parents=True, exist_ok=True) - export_path = export_dir / f"performance.{export_format}" + # Capture the output + captured = capsys.readouterr() + output = captured.out.strip() - performance_stats = { - "cpu_usage": 60.0, - "memory_usage": 4096, - "timestamp": "2025-06-13T12:05:00" - } + # Assert output format (basic check) + assert output.startswith("Function 'dummy_function' executed in ") - exporter = PerformanceExporter(str(export_path), export_format, frequency=2) + # Extract the ms part and check it's within reasonable range + match = re.search(r'executed in ([\d\.]+) ms', output) + assert match is not None + elapsed_ms = float(match.group(1)) + + assert 90 <= elapsed_ms <= 200 # Allowing some tolerance - # First export should always succeed - exporter.export(performance_stats) - first_mtime = os.path.getmtime(export_path) +def test_track_decorator_returns_original_value(): + exporter = BuiltinPerformanceExporter() - # Immediately try to export again β€” should be skipped - exporter.export(performance_stats) - second_mtime = os.path.getmtime(export_path) - assert first_mtime == second_mtime, "File was not modified because of frequency constraint." + @exporter.track + def add(a, b): + return a + b - # Wait for frequency to expire - time.sleep(2.1) - exporter.export(performance_stats) - third_mtime = os.path.getmtime(export_path) - assert third_mtime > second_mtime, "File was modified after frequency interval." + result = add(3, 5) + assert result == 8 diff --git a/tests/test_performance_exporter_v2.py b/tests/test_performance_exporter_v2.py deleted file mode 100644 index e362847..0000000 --- a/tests/test_performance_exporter_v2.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -import json -import csv -import pytest -import time - -from pyquerytracker.performance_exporter import PerformanceExporter - -# Define persistent output directory -OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "output") -os.makedirs(OUTPUT_DIR, exist_ok=True) - -@pytest.mark.parametrize("export_format", ['json', 'csv']) -def test_performance_exporter_persistent(export_format): - export_path = os.path.join(OUTPUT_DIR, f"performance.{export_format}") - - performance_stats = { - "cpu_usage": 55.2, - "memory_usage": 2048, - "timestamp": "2025-06-13T12:00:00" - } - - exporter = PerformanceExporter(export_path, export_format) - exporter.export(performance_stats) - - assert os.path.exists(export_path) - - if export_format == 'json': - with open(export_path, 'r') as f: - data = json.load(f) - assert data == performance_stats - elif export_format == 'csv': - with open(export_path, 'r', newline='') as f: - reader = csv.reader(f) - rows = list(reader) - assert rows[0] == ['Metric', 'Value'] - exported_data = {row[0]: row[1] for row in rows[1:]} - exported_data = { - k: float(v) if k in ['cpu_usage', 'memory_usage'] else v - for k, v in exported_data.items() - } - assert exported_data["cpu_usage"] == performance_stats["cpu_usage"] - assert exported_data["memory_usage"] == performance_stats["memory_usage"] - assert exported_data["timestamp"] == performance_stats["timestamp"] - -@pytest.mark.parametrize("export_format", ['json', 'csv']) -def test_exporter_frequency_persistent(export_format): - export_path = os.path.join(OUTPUT_DIR, f"performance_freq.{export_format}") - - performance_stats = { - "cpu_usage": 60.0, - "memory_usage": 4096, - "timestamp": "2025-06-13T12:05:00" - } - - exporter = PerformanceExporter(export_path, export_format, frequency=2) - - # First export should succeed - exporter.export(performance_stats) - first_mtime = os.path.getmtime(export_path) - - # Immediate second export β€” should be skipped - exporter.export(performance_stats) - second_mtime = os.path.getmtime(export_path) - assert first_mtime == second_mtime, "File should not have been modified due to frequency limit." - - # Wait for frequency period to pass - time.sleep(2.1) - exporter.export(performance_stats) - third_mtime = os.path.getmtime(export_path) - assert third_mtime > second_mtime, "File should be modified after frequency period."