From 01e7e7485460df8d3f130ba120c5046022c6c144 Mon Sep 17 00:00:00 2001 From: kahu-bit Date: Fri, 23 Jan 2026 20:41:04 +1300 Subject: [PATCH 01/43] Delete .github/workflows/ci.yml --- .github/workflows/ci.yml | 43 ---------------------------------------- 1 file changed, 43 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4f51f54d..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: CI Pipeline - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install ruff pytest - - - name: Run linter (ruff) - run: ruff check . - - - name: Run tests (pytest) - run: pytest -v - - - name: Build distribution - run: | - pip install build - python -m build - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ \ No newline at end of file From e008ea58fbcc551fad8bee6541fb351ee6036e21 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Fri, 23 Jan 2026 21:15:49 +1300 Subject: [PATCH 02/43] Added workflow --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ app.py | 5 +++++ requirements.txt | 1 - tests/test_app.py | 11 ++++++++++- 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d0863fdd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run tests + run: pytest -v diff --git a/app.py b/app.py index 8f2f7ae1..ece4f19f 100644 --- a/app.py +++ b/app.py @@ -14,3 +14,8 @@ def is_even(n: int) -> bool: def reverse_string(s: str) -> str: """Reverse a string.""" return s[::-1] + + +def multiply(a,b): + """Multiply two integers, a and b""" + return a * b diff --git a/requirements.txt b/requirements.txt index 27065103..5f456517 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,4 @@ # Development/testing dependencies pytest>=7.0.0 -ruff>=0.1.0 build>=1.0.0 diff --git a/tests/test_app.py b/tests/test_app.py index 79c3e093..2a666f5b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,6 +1,6 @@ """Tests for app.py - you'll add more!""" -from app import add, is_even, reverse_string +from app import add, is_even, reverse_string, multiply class TestMath: @@ -12,6 +12,15 @@ def test_add_positive(self): def test_add_negative(self): assert add(-1, -1) == -2 + def test_multiple_two_positive(self): + assert multiply(5, 5) == 25 + + def test_multiple_zero(self): + assert multiply(0, 5) == 0 + + def test_multiple_two_negative(self): + assert multiply(-2,-2) == 4 + class TestStrings: """Tests for string functions.""" From 956dfa8708c136a9b431f49194ed23271c3127ca Mon Sep 17 00:00:00 2001 From: CARWHO Date: Fri, 23 Jan 2026 21:19:43 +1300 Subject: [PATCH 03/43] Break a test --- tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index 2a666f5b..bb212876 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,7 +16,7 @@ def test_multiple_two_positive(self): assert multiply(5, 5) == 25 def test_multiple_zero(self): - assert multiply(0, 5) == 0 + assert multiply(0, 5) == 5 def test_multiple_two_negative(self): assert multiply(-2,-2) == 4 From ebfa8eb77a035bb477cd9ea5908e455773584909 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Fri, 23 Jan 2026 21:20:48 +1300 Subject: [PATCH 04/43] Fix the test --- tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index bb212876..2a666f5b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -16,7 +16,7 @@ def test_multiple_two_positive(self): assert multiply(5, 5) == 25 def test_multiple_zero(self): - assert multiply(0, 5) == 5 + assert multiply(0, 5) == 0 def test_multiple_two_negative(self): assert multiply(-2,-2) == 4 From a45d8bffc83d4ab408caa0b4c575759893ca1797 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Fri, 23 Jan 2026 21:28:55 +1300 Subject: [PATCH 05/43] Add build and artifact upload to CI --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0863fdd..d43c223f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,13 @@ jobs: - name: Run tests run: pytest -v + + - name: Build package + run: python -m build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: python-package + path: dist/ + From 53cb0576f7c1c89360c85da2fe31be29cbb86b56 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Fri, 23 Jan 2026 21:54:07 +1300 Subject: [PATCH 06/43] Add multiply function, tests, and CI workflow --- .github/workflows/ci.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d43c223f..d0863fdd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,3 @@ jobs: - name: Run tests run: pytest -v - - - name: Build package - run: python -m build - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: python-package - path: dist/ - From d8426873057fd0baed65039b78bf3163eb4bd153 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Fri, 23 Jan 2026 21:56:15 +1300 Subject: [PATCH 07/43] Break a test --- tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index 2a666f5b..6fad53c9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -10,7 +10,7 @@ def test_add_positive(self): assert add(2, 3) == 5 def test_add_negative(self): - assert add(-1, -1) == -2 + assert add(-1, -1) == 1 def test_multiple_two_positive(self): assert multiply(5, 5) == 25 From bf4657821c91caf9b0147858b7b962e1522d017f Mon Sep 17 00:00:00 2001 From: CARWHO Date: Fri, 23 Jan 2026 21:57:18 +1300 Subject: [PATCH 08/43] Fix the test --- tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index 6fad53c9..2a666f5b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -10,7 +10,7 @@ def test_add_positive(self): assert add(2, 3) == 5 def test_add_negative(self): - assert add(-1, -1) == 1 + assert add(-1, -1) == -2 def test_multiple_two_positive(self): assert multiply(5, 5) == 25 From 0f9409dd9d841c9e61c9a679af97e01a9278addb Mon Sep 17 00:00:00 2001 From: CARWHO Date: Sat, 24 Jan 2026 08:06:38 +1300 Subject: [PATCH 09/43] Add build and artifact upload to CI --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0863fdd..873f6ae2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,12 @@ jobs: - name: Run tests run: pytest -v + + - name: Build package + run: python -m build + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: python-package + path: dist/ From ee6888b1a140622f315023d1750d9438e22a8625 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Sat, 24 Jan 2026 22:54:58 +1300 Subject: [PATCH 10/43] Add AI code review script and PR workflow --- .github/pr-review.yml | 56 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + scripts/ai_review.py | 39 ++++++++++++++++++++++++++++ scripts/sample_diff.txt | 17 +++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 .github/pr-review.yml create mode 100644 scripts/ai_review.py create mode 100644 scripts/sample_diff.txt diff --git a/.github/pr-review.yml b/.github/pr-review.yml new file mode 100644 index 00000000..1bbebca3 --- /dev/null +++ b/.github/pr-review.yml @@ -0,0 +1,56 @@ +name: AI Code Review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Get PR diff + run: | + git diff origin/main...HEAD > pr_diff.txt + + - name: Run AI review + id: ai-review + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + REVIEW=$(python scripts/ai_review.py pr_diff.txt) + echo "review<> $GITHUB_OUTPUT + echo "$REVIEW" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Post review comment + uses: actions/github-script@v7 + env: + REVIEW: ${{ steps.ai-review.outputs.review }} + with: + script: | + const review = process.env.REVIEW; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `## 🤖 AI Code Review\n\n${review}\n\n---\n*Powered by Gemini AI*` + }); diff --git a/requirements.txt b/requirements.txt index 5f456517..280da611 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ # Production dependencies (none for this simple app) +google-generativeai>=0.3.0 # Development/testing dependencies pytest>=7.0.0 diff --git a/scripts/ai_review.py b/scripts/ai_review.py new file mode 100644 index 00000000..b97398e5 --- /dev/null +++ b/scripts/ai_review.py @@ -0,0 +1,39 @@ +from google import genai +import sys + +client = genai.Client() + +# Define a function that takes a code diff as input +def review_code(diff_text): + + # Write a multi-line f-string prompt that includes {diff_text} + # Tell Gemini to act as a code reviewer and focus on security, bugs, performance + prompt = f""" Act as a code reviewer and focus on security, bugs and performance {diff_text}""" + + # Send the prompt to the model and get a response + response = client.models.generate_content( + model="gemini-2.5-flash", contents=prompt + ) + + # Return just the text from the response + return response.text + +# Only run this code when the script is executed directly +if __name__ == "__main__": + + # Check if a filename was passed as a command-line argument + if len(sys.argv) > 1: + + # Get the filename from sys.argv and read the file + diff_file = sys.argv[1] + with open(diff_file, "r") as f: + diff_content = f.read() + + # If no filename was passed, read from standard input + else: + diff_content = sys.stdin.read() + + # Call the review function and print the result + review = review_code(diff_content) + print(review) + diff --git a/scripts/sample_diff.txt b/scripts/sample_diff.txt new file mode 100644 index 00000000..d4b5984b --- /dev/null +++ b/scripts/sample_diff.txt @@ -0,0 +1,17 @@ +diff --git a/app.py b/app.py +index 1234567..abcdefg 100644 +--- a/app.py ++++ b/app.py +@@ -1,5 +1,12 @@ + """Simple utility functions""" + ++import sqlite3 ++ ++def get_user(username): ++ conn = sqlite3.connect("users.db") ++ query = f"SELECT * FROM users WHERE name = '{username}'" ++ return conn.execute(query).fetchone() ++ + def add(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b From 3401e71a53af76726a778d8afa16ca83f867350e Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 21:27:54 +1300 Subject: [PATCH 11/43] Add test generation script and workflow --- .github/{ => workflows}/pr-review.yml | 0 .github/workflows/test-gen.yml | 59 +++++++++++++ scripts/generate_tests.py | 121 ++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) rename .github/{ => workflows}/pr-review.yml (100%) create mode 100644 .github/workflows/test-gen.yml create mode 100644 scripts/generate_tests.py diff --git a/.github/pr-review.yml b/.github/workflows/pr-review.yml similarity index 100% rename from .github/pr-review.yml rename to .github/workflows/pr-review.yml diff --git a/.github/workflows/test-gen.yml b/.github/workflows/test-gen.yml new file mode 100644 index 00000000..cc091c55 --- /dev/null +++ b/.github/workflows/test-gen.yml @@ -0,0 +1,59 @@ +name: AI Test Generation + +on: + pull_request: + types: [opened, synchronize] + paths: + - '**.py' + - '!tests/**' + +jobs: + generate-tests: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -r requirements.txt + + - name: Get changed Python files + id: changed-files + run: | + FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD -- '*.py' | grep -v '^tests/' | tr '\n' ' ') + echo "files=$FILES" >> $GITHUB_OUTPUT + + - name: Generate tests + if: steps.changed-files.outputs.files != '' + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + python scripts/generate_tests.py ${{ steps.changed-files.outputs.files }} + + - name: Commit generated tests + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git diff --quiet tests/; then + echo "No new tests generated" + exit 0 + fi + + git add tests/ + git commit -m "Auto-generate tests for new code [skip ci]" + git push diff --git a/scripts/generate_tests.py b/scripts/generate_tests.py new file mode 100644 index 00000000..3eb479c5 --- /dev/null +++ b/scripts/generate_tests.py @@ -0,0 +1,121 @@ +import ast +import os +import sys +from google import genai + +# Define a function that takes a file path and extracts function info +def extract_functions(file_path): + """Parse a Python file and extract function definitions.""" + + # Open the file and read its contents into a string + with open(file_path, 'r') as f: + source = f.read() + + # Parse the source code into an AST tree + tree = ast.parse(source) + functions = [] + + # Walk through every node in the AST + for node in ast.walk(tree): + # Check if this node is a function definition + if isinstance(node, ast.FunctionDef): + func_name = node.name + args = [arg.arg for arg in node.args.args] + docstring = ast.get_docstring(node) or "" + func_source = ast.get_source_segment(source, node) + + functions.append({ + 'name': func_name, + 'args': args, + 'docstring': docstring, + 'source': func_source + }) + + return functions + +# Define a function that generates tests for a given function's info +def generate_function_tests(func_info): + """Use Gemini to generate pytest tests for a function.""" + + # Create a Gemini client using your API key from environment variables + client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY")) + + # Write a multi-line prompt that includes the function details + prompt = f"""Generate pytest tests for this Python function. + +Function name: {func_info['name']} +Arguments: {', '.join(func_info['args'])} +Docstring: {func_info['docstring']} + +Source code: + +{func_info['source']} + +Requirements: +1. Generate 3-5 meaningful test cases +2. Include edge cases (empty inputs, None values, etc.) +3. Use descriptive test function names +4. Include assertions that actually test behavior +5. Do NOT generate placeholder tests like `assert True` + +Return ONLY the Python test code, no explanations. +""" + + # Send the prompt to the model and get a response + response = client.models.generate_content( + model='gemini-2.5-pro', + contents=prompt + ) + + # Return just the text from the response + return response.text + +def main(): + """Main function to generate tests for changed files.""" + + # Get list of changed Python files from command-line arguments + changed_files = sys.argv[1:] if len(sys.argv) > 1 else [] + + if not changed_files: + print("No Python files provided for test generation") + return + + all_tests = [] + + for file_path in changed_files: + # Skip non-Python files + if not file_path.endswith('.py'): + continue + # Skip test files + if file_path.startswith('/tests'): + continue + + print(f"Analyzing: {file_path}") + functions = extract_functions(file_path) + + for func in functions: + # Skip private functions (those starting with _) + if func['name'].startswith('_'): + continue + + print(f" Generating tests for: {func['name']}") + tests = generate_function_tests(func) + all_tests.append(f"# Tests for {func['name']} from {file_path}\n{tests}") + + if all_tests: + # Create tests directory if it doesn't exist + os.makedirs('tests', exist_ok=True) + test_file = 'tests/test_generated.py' + + with open(test_file, 'w') as f: + f.write("import pytest\n\n") + f.write("\n\n".join(all_tests)) + + print(f"Generated tests written to: {test_file}") + else: + print("No functions found to generate tests for") + + +# Only run this code when the script is executed directly +if __name__ == "__main__": + main() From e581b00f335606561c2a0ef55f8fd545d6d63b0a Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 21:34:42 +1300 Subject: [PATCH 12/43] Add factorial function --- app.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app.py b/app.py index ece4f19f..aec33cb3 100644 --- a/app.py +++ b/app.py @@ -19,3 +19,19 @@ def reverse_string(s: str) -> str: def multiply(a,b): """Multiply two integers, a and b""" return a * b + +def factorial(n): + """Calculate the factorial of n.""" + + # Handle invalid input - factorial isn't defined for negatives + if n < 0: + raise ValueError( + "Factorial not defined for negative numbers" + ) + + # Base case - stop recursion when n is 0 or 1 + if n <= 1: + return 1 + + # Recursive case - n! = n × (n-1)! + return n * factorial(n-1) \ No newline at end of file From 7c6eab24d25c4b8dbc05d451e62e31d4d51e7bf2 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 21:40:34 +1300 Subject: [PATCH 13/43] Add factorial function --- requirements.txt | 2 +- uv.lock | 277 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 uv.lock diff --git a/requirements.txt b/requirements.txt index 280da611..389d026e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Production dependencies (none for this simple app) -google-generativeai>=0.3.0 +google-genai>=1.0.0 # Development/testing dependencies pytest>=7.0.0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..ac509dba --- /dev/null +++ b/uv.lock @@ -0,0 +1,277 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "build" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" }, +] + +[[package]] +name = "ci-starter" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +dev = [ + { name = "build" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "build", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, +] +provides-extras = ["dev"] + +[[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 = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +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 = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[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.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +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 = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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" }, +] From 6b8f8da70eeaeecb20f9951a7a7cd81561356d6d Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 21:43:26 +1300 Subject: [PATCH 14/43] Add factorial function --- scripts/generate_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generate_tests.py b/scripts/generate_tests.py index 3eb479c5..1a71b950 100644 --- a/scripts/generate_tests.py +++ b/scripts/generate_tests.py @@ -63,7 +63,7 @@ def generate_function_tests(func_info): # Send the prompt to the model and get a response response = client.models.generate_content( - model='gemini-2.5-pro', + model='gemini-2.5-flash', contents=prompt ) From 4e96d9102324d9bdf3bfba7acfb22cebb789b065 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 21:50:35 +1300 Subject: [PATCH 15/43] Add factorial function --- .github/workflows/test-gen.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-gen.yml b/.github/workflows/test-gen.yml index cc091c55..95f15b78 100644 --- a/.github/workflows/test-gen.yml +++ b/.github/workflows/test-gen.yml @@ -49,7 +49,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - if git diff --quiet tests/; then + if [ -z "$(git status --porcelain tests/)" ]; then echo "No new tests generated" exit 0 fi From 3d31fc5c528fa3c80385ef19fa32d0fd57bd441e Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:00:00 +1300 Subject: [PATCH 16/43] Add factorial function --- scripts/generate_tests.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/generate_tests.py b/scripts/generate_tests.py index 1a71b950..e331d0f0 100644 --- a/scripts/generate_tests.py +++ b/scripts/generate_tests.py @@ -1,7 +1,9 @@ import ast import os import sys +import time from google import genai +from google.genai import errors # Define a function that takes a file path and extracts function info def extract_functions(file_path): @@ -34,7 +36,7 @@ def extract_functions(file_path): return functions # Define a function that generates tests for a given function's info -def generate_function_tests(func_info): +def generate_function_tests(func_info, max_retries=5, initial_delay=60): """Use Gemini to generate pytest tests for a function.""" # Create a Gemini client using your API key from environment variables @@ -61,14 +63,27 @@ def generate_function_tests(func_info): Return ONLY the Python test code, no explanations. """ - # Send the prompt to the model and get a response - response = client.models.generate_content( - model='gemini-2.5-flash', - contents=prompt - ) - - # Return just the text from the response - return response.text + # Retry loop with exponential backoff for rate limits + delay = initial_delay + for attempt in range(max_retries): + try: + # Send the prompt to the model and get a response + response = client.models.generate_content( + model='gemini-2.5-flash', + contents=prompt + ) + # Return just the text from the response + return response.text + except errors.ClientError as e: + if '429' in str(e) or 'RESOURCE_EXHAUSTED' in str(e): + if attempt < max_retries - 1: + print(f" Rate limited. Waiting {delay}s before retry...") + time.sleep(delay) + delay *= 2 # Exponential backoff + else: + raise + else: + raise def main(): """Main function to generate tests for changed files.""" From b477870f14d1154447af97ba923beb8ff2900c44 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:04:42 +1300 Subject: [PATCH 17/43] Add factorial function --- scripts/generate_tests.py | 47 ++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/scripts/generate_tests.py b/scripts/generate_tests.py index e331d0f0..96e95c78 100644 --- a/scripts/generate_tests.py +++ b/scripts/generate_tests.py @@ -35,32 +35,40 @@ def extract_functions(file_path): return functions -# Define a function that generates tests for a given function's info -def generate_function_tests(func_info, max_retries=5, initial_delay=60): - """Use Gemini to generate pytest tests for a function.""" +# Define a function that generates tests for multiple functions in one API call +def generate_all_tests(all_functions, max_retries=5, initial_delay=60): + """Use Gemini to generate pytest tests for all functions in one call.""" # Create a Gemini client using your API key from environment variables client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY")) - # Write a multi-line prompt that includes the function details - prompt = f"""Generate pytest tests for this Python function. - + # Build a combined prompt with all functions + functions_text = "" + for file_path, func_info in all_functions: + functions_text += f""" +--- Function from {file_path} --- Function name: {func_info['name']} Arguments: {', '.join(func_info['args'])} Docstring: {func_info['docstring']} Source code: - {func_info['source']} +""" + + prompt = f"""Generate pytest tests for these Python functions. + +{functions_text} + Requirements: -1. Generate 3-5 meaningful test cases +1. Generate 3-5 meaningful test cases per function 2. Include edge cases (empty inputs, None values, etc.) 3. Use descriptive test function names 4. Include assertions that actually test behavior 5. Do NOT generate placeholder tests like `assert True` +6. Add necessary imports at the top (like 'from app import add, multiply' etc.) -Return ONLY the Python test code, no explanations. +Return ONLY the Python test code, no explanations or markdown code blocks. """ # Retry loop with exponential backoff for rate limits @@ -77,7 +85,7 @@ def generate_function_tests(func_info, max_retries=5, initial_delay=60): except errors.ClientError as e: if '429' in str(e) or 'RESOURCE_EXHAUSTED' in str(e): if attempt < max_retries - 1: - print(f" Rate limited. Waiting {delay}s before retry...") + print(f" Rate limited. Waiting {delay}s before retry...") time.sleep(delay) delay *= 2 # Exponential backoff else: @@ -95,14 +103,15 @@ def main(): print("No Python files provided for test generation") return - all_tests = [] + # Collect all functions first + all_functions = [] for file_path in changed_files: # Skip non-Python files if not file_path.endswith('.py'): continue # Skip test files - if file_path.startswith('/tests'): + if file_path.startswith('tests/'): continue print(f"Analyzing: {file_path}") @@ -113,18 +122,20 @@ def main(): if func['name'].startswith('_'): continue - print(f" Generating tests for: {func['name']}") - tests = generate_function_tests(func) - all_tests.append(f"# Tests for {func['name']} from {file_path}\n{tests}") + print(f" Found function: {func['name']}") + all_functions.append((file_path, func)) + + if all_functions: + # Generate tests for all functions in one API call + print(f"\nGenerating tests for {len(all_functions)} functions...") + tests = generate_all_tests(all_functions) - if all_tests: # Create tests directory if it doesn't exist os.makedirs('tests', exist_ok=True) test_file = 'tests/test_generated.py' with open(test_file, 'w') as f: - f.write("import pytest\n\n") - f.write("\n\n".join(all_tests)) + f.write(tests) print(f"Generated tests written to: {test_file}") else: From 32c1ac96ac0868fabe171f5332b240eba0b82658 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 09:05:52 +0000 Subject: [PATCH 18/43] Auto-generate tests for new code [skip ci] --- tests/test_generated.py | 442 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 442 insertions(+) create mode 100644 tests/test_generated.py diff --git a/tests/test_generated.py b/tests/test_generated.py new file mode 100644 index 00000000..c952e493 --- /dev/null +++ b/tests/test_generated.py @@ -0,0 +1,442 @@ +```python +import pytest +import os +import sys +import ast +import time +from unittest import mock + +# Assuming the functions are in 'app.py' and 'scripts/generate_tests.py' +# We need to make sure these modules are importable. +# For testing purposes, we might directly import them or adjust sys.path. +# For this output, I'll assume a structure that allows direct import if run from a specific location, +# or for a test runner that handles module discovery. + +# --- Imports for app.py functions --- +# If app.py is in the current directory, these imports work: +try: + from app import add, is_even, reverse_string, multiply, factorial +except ImportError: + # Fallback for environments where app.py might not be directly importable + # This might happen if running tests from a different directory. + # For a real test setup, you'd ensure app.py is in PYTHONPATH or imported correctly. + # For this exercise, we'll define dummy functions if import fails to allow tests to run structurally. + print("Warning: Could not import functions from app.py. Using dummy implementations for tests.") + def add(a: int, b: int) -> int: return a + b + def is_even(n: int) -> bool: return n % 2 == 0 + def reverse_string(s: str) -> str: return s[::-1] + def multiply(a, b): return a * b + def factorial(n): + if n < 0: raise ValueError("Factorial not defined for negative numbers") + if n <= 1: return 1 + return n * factorial(n - 1) + + +# --- Imports for scripts/generate_tests.py functions --- +# Similarly, adjust import based on your project structure. +try: + from scripts.generate_tests import extract_functions, generate_all_tests, main + # Mocking genai.errors and genai.Client for generate_all_tests + # These imports are needed for type hinting or exception handling within the mocked function + from google.generativeai import Client, errors +except ImportError: + print("Warning: Could not import functions from scripts/generate_tests.py. Using dummy implementations for tests.") + # Dummy implementations for testing structure + def extract_functions(file_path): return [] + def generate_all_tests(all_functions, max_retries=5, initial_delay=60): return "mock_test_code" + def main(): pass + # Dummy classes for mocking purposes if the actual module is not available + class MockClient: + def models(self): + return self + def generate_content(self, model, contents): + raise MockClientError("Mock error") # Default to error for testing retry logic + class MockClientError(Exception): + def __init__(self, message, code=None): + super().__init__(message) + self.code = code + Client = MockClient + errors = mock.MagicMock() + errors.ClientError = MockClientError + + +# --- Tests for app.py functions --- + +def test_add_positive_numbers(): + assert add(2, 3) == 5 + +def test_add_negative_numbers(): + assert add(-5, -7) == -12 + +def test_add_mixed_numbers(): + assert add(10, -4) == 6 + +def test_add_zero(): + assert add(0, 8) == 8 + assert add(5, 0) == 5 + assert add(0, 0) == 0 + +def test_is_even_positive_even(): + assert is_even(4) is True + +def test_is_even_positive_odd(): + assert is_even(7) is False + +def test_is_even_negative_even(): + assert is_even(-2) is True + +def test_is_even_negative_odd(): + assert is_even(-9) is False + +def test_is_even_zero(): + assert is_even(0) is True + +def test_reverse_string_basic(): + assert reverse_string("hello") == "olleh" + +def test_reverse_string_with_spaces(): + assert reverse_string("hello world") == "dlrow olleh" + +def test_reverse_string_empty(): + assert reverse_string("") == "" + +def test_reverse_string_single_character(): + assert reverse_string("a") == "a" + +def test_reverse_string_palindrome(): + assert reverse_string("madam") == "madam" + +def test_multiply_positive_numbers(): + assert multiply(3, 4) == 12 + +def test_multiply_negative_numbers(): + assert multiply(-2, -5) == 10 + +def test_multiply_mixed_signs(): + assert multiply(6, -3) == -18 + assert multiply(-7, 2) == -14 + +def test_multiply_by_zero(): + assert multiply(10, 0) == 0 + assert multiply(0, -5) == 0 + assert multiply(0, 0) == 0 + +def test_multiply_by_one(): + assert multiply(5, 1) == 5 + assert multiply(-8, 1) == -8 + +def test_factorial_zero(): + assert factorial(0) == 1 + +def test_factorial_one(): + assert factorial(1) == 1 + +def test_factorial_positive_number(): + assert factorial(3) == 6 # 3*2*1 + assert factorial(5) == 120 # 5*4*3*2*1 + +def test_factorial_negative_number_raises_error(): + with pytest.raises(ValueError, match="Factorial not defined for negative numbers"): + factorial(-1) + with pytest.raises(ValueError, match="Factorial not defined for negative numbers"): + factorial(-5) + + +# --- Tests for scripts/generate_tests.py functions --- + +@pytest.fixture +def sample_python_file(tmp_path): + """Fixture to create a temporary Python file for testing extract_functions.""" + file_content = """ +import os + +def func_a(arg1, arg2): + \"\"\"Docstring for func_a.\"\"\" + return arg1 + arg2 + +class MyClass: + def method_b(self): + pass + +def _private_func(x): + \"\"\"This is a private function.\"\"\" + return x * 2 + +def func_c(): + \"\"\"No args, simple function.\"\"\" + pass +""" + file_path = tmp_path / "sample_module.py" + file_path.write_text(file_content) + return file_path + +@pytest.fixture +def empty_python_file(tmp_path): + """Fixture for an empty Python file.""" + file_path = tmp_path / "empty_module.py" + file_path.write_text("") + return file_path + +@pytest.fixture +def no_function_python_file(tmp_path): + """Fixture for a Python file with no functions.""" + file_content = """ +import sys + +x = 10 +y = "hello" +""" + file_path = tmp_path / "no_func_module.py" + file_path.write_text(file_content) + return file_path + +def test_extract_functions_basic_case(sample_python_file): + functions = extract_functions(sample_python_file) + assert len(functions) == 3 # func_a, _private_func, func_c + + func_names = [f['name'] for f in functions] + assert "func_a" in func_names + assert "_private_func" in func_names + assert "func_c" in func_names + + func_a_info = next(f for f in functions if f['name'] == 'func_a') + assert func_a_info['args'] == ['arg1', 'arg2'] + assert func_a_info['docstring'] == "Docstring for func_a." + assert "return arg1 + arg2" in func_a_info['source'] + + func_c_info = next(f for f in functions if f['name'] == 'func_c') + assert func_c_info['args'] == [] + assert func_c_info['docstring'] == "No args, simple function." + + +def test_extract_functions_empty_file(empty_python_file): + functions = extract_functions(empty_python_file) + assert len(functions) == 0 + +def test_extract_functions_no_functions_file(no_function_python_file): + functions = extract_functions(no_function_python_file) + assert len(functions) == 0 + +def test_extract_functions_file_not_found(): + with pytest.raises(FileNotFoundError): + extract_functions("non_existent_file.py") + +def test_extract_functions_with_no_docstring(tmp_path): + file_content = """ +def func_no_doc(): + pass +""" + file_path = tmp_path / "no_doc_module.py" + file_path.write_text(file_content) + functions = extract_functions(file_path) + assert len(functions) == 1 + assert functions[0]['name'] == 'func_no_doc' + assert functions[0]['docstring'] == "" # Should be an empty string + +# Mock the genai client for generate_all_tests +@pytest.fixture +def mock_genai_client(mocker): + mock_client = mocker.Mock(spec=Client) + mock_models = mocker.Mock() + mock_generate_content = mocker.Mock() + mock_response = mocker.Mock() + mock_response.text = "Generated test code content" + + mock_generate_content.return_value = mock_response + mock_models.generate_content.return_value = mock_response + mock_client.models.return_value = mock_models + mocker.patch('google.generativeai.Client', return_value=mock_client) + return mock_generate_content + +def test_generate_all_tests_success(mock_genai_client): + mock_functions = [ + ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add two numbers.', 'source': 'def add(a, b): return a + b'}), + ('app.py', {'name': 'is_even', 'args': ['n'], 'docstring': 'Check even.', 'source': 'def is_even(n): return n % 2 == 0'}), + ] + expected_output = "Generated test code content" + + result = generate_all_tests(mock_functions) + assert result == expected_output + mock_genai_client.assert_called_once() + + # Verify prompt content + call_args, _ = mock_genai_client.call_args + prompt = call_args[1]['contents'] # contents argument of generate_content + assert "--- Function from app.py ---" in prompt + assert "Function name: add" in prompt + assert "Arguments: a, b" in prompt + assert "Docstring: Add two numbers." in prompt + assert "Source code:\ndef add(a, b): return a + b" in prompt + assert "Function name: is_even" in prompt + assert "Requirements:" in prompt + assert "Return ONLY the Python test code, no explanations or markdown code blocks." in prompt + +def test_generate_all_tests_rate_limit_retry(mocker, mock_genai_client): + # Simulate a 429 error twice, then success + mock_genai_client.side_effect = [ + errors.ClientError("429 RESOURCE_EXHAUSTED", code=429), + errors.ClientError("429 RESOURCE_EXHAUSTED", code=429), + mocker.Mock(text="Generated test code content after retries") + ] + mocker.patch('time.sleep', return_value=None) # Prevent actual sleep during test + + mock_functions = [ + ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add two numbers.', 'source': 'def add(a, b): return a + b'}), + ] + + result = generate_all_tests(mock_functions, max_retries=3, initial_delay=0.01) + assert result == "Generated test code content after retries" + assert mock_genai_client.call_count == 3 # Two retries + one successful call + # Check that sleep was called (at least once for each retry) + assert mocker.patch('time.sleep').call_count >= 2 + +def test_generate_all_tests_rate_limit_exceeded(mocker, mock_genai_client): + # Simulate rate limit failure for all retries + mock_genai_client.side_effect = errors.ClientError("429 RESOURCE_EXHAUSTED", code=429) + mocker.patch('time.sleep', return_value=None) + + mock_functions = [ + ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add two numbers.', 'source': 'def add(a, b): return a + b'}), + ] + + with pytest.raises(errors.ClientError, match="429 RESOURCE_EXHAUSTED"): + generate_all_tests(mock_functions, max_retries=2, initial_delay=0.01) + assert mock_genai_client.call_count == 2 # Max retries attempted + +# Mock various components for the main function +@pytest.fixture +def mock_main_dependencies(mocker, tmp_path): + # Mock sys.argv + mocker.patch('sys.argv', ['script_name', str(tmp_path / 'app.py'), str(tmp_path / 'scripts/generate_tests.py')]) + + # Mock os.makedirs + mocker.patch('os.makedirs') + + # Mock extract_functions to return a controlled list of functions + mock_extract_functions = mocker.patch('scripts.generate_tests.extract_functions') + mock_extract_functions.side_effect = [ + # Return for 'app.py' + [ + {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add.', 'source': 'def add(a,b):pass'}, + {'name': 'is_even', 'args': ['n'], 'docstring': 'Even.', 'source': 'def is_even(n):pass'}, + {'name': '_private_func', 'args': [], 'docstring': 'Private.', 'source': 'def _private_func():pass'}, # Should be filtered out + ], + # Return for 'scripts/generate_tests.py' + [ + {'name': 'extract_functions', 'args': ['file_path'], 'docstring': 'Extract.', 'source': 'def extract_functions(f):pass'} + ] + ] + + # Mock generate_all_tests to return a dummy test string + mock_generate_all_tests = mocker.patch('scripts.generate_tests.generate_all_tests', return_value=""" +# Generated test content +def test_add_generated(): + assert 1 + 1 == 2 +""") + + # Mock open for writing the test file + mock_open = mocker.mock_open() + mocker.patch('builtins.open', mock_open) + + return { + 'sys_argv': mocker.patch('sys.argv'), + 'os_makedirs': mocker.patch('os.makedirs'), + 'extract_functions': mock_extract_functions, + 'generate_all_tests': mock_generate_all_tests, + 'mock_open': mock_open, + 'tmp_path': tmp_path + } + + +def test_main_generates_tests_for_multiple_files(mock_main_dependencies, capsys): + # Create dummy files for sys.argv to point to + mock_main_dependencies['tmp_path'].joinpath('app.py').write_text("...") + mock_main_dependencies['tmp_path'].joinpath('scripts/generate_tests.py').write_text("...") + + # Set sys.argv to include the dummy file paths + sys.argv = ['script_name', str(mock_main_dependencies['tmp_path'] / 'app.py'), str(mock_main_dependencies['tmp_path'] / 'scripts/generate_tests.py')] + + main() + + # Assertions for main's behavior + mock_main_dependencies['os_makedirs'].assert_called_once_with('tests', exist_ok=True) + + # Check extract_functions calls (one per file) + assert mock_main_dependencies['extract_functions'].call_count == 2 + mock_main_dependencies['extract_functions'].assert_any_call(str(mock_main_dependencies['tmp_path'] / 'app.py')) + mock_main_dependencies['extract_functions'].assert_any_call(str(mock_main_dependencies['tmp_path'] / 'scripts/generate_tests.py')) + + # Check generate_all_tests call + mock_main_dependencies['generate_all_tests'].assert_called_once() + args, _ = mock_main_dependencies['generate_all_tests'].call_args + # It should have collected 3 functions: add, is_even, extract_functions (private _func filtered) + assert len(args[0]) == 3 + assert ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add.', 'source': 'def add(a,b):pass'}) in args[0] + assert ('app.py', {'name': 'is_even', 'args': ['n'], 'docstring': 'Even.', 'source': 'def is_even(n):pass'}) in args[0] + assert ('scripts/generate_tests.py', {'name': 'extract_functions', 'args': ['file_path'], 'docstring': 'Extract.', 'source': 'def extract_functions(f):pass'}) in args[0] + + + # Check file writing + mock_main_dependencies['mock_open'].assert_called_once_with('tests/test_generated.py', 'w') + mock_main_dependencies['mock_open']().write.assert_called_once_with(""" +# Generated test content +def test_add_generated(): + assert 1 + 1 == 2 +""") + + # Check print statements + captured = capsys.readouterr() + assert "Analyzing:" in captured.out + assert "Found function: add" in captured.out + assert "Found function: is_even" in captured.out + assert "Found function: extract_functions" in captured.out + assert "Generating tests for 3 functions..." in captured.out + assert "Generated tests written to: tests/test_generated.py" in captured.out + +def test_main_no_files_provided(mocker, capsys): + mocker.patch('sys.argv', ['script_name']) + mocker.patch('scripts.generate_tests.extract_functions') + mocker.patch('scripts.generate_tests.generate_all_tests') + + main() + captured = capsys.readouterr() + assert "No Python files provided for test generation" in captured.out + mocker.patch('scripts.generate_tests.extract_functions').assert_not_called() + mocker.patch('scripts.generate_tests.generate_all_tests').assert_not_called() + +def test_main_no_functions_found(mocker, capsys, tmp_path): + dummy_file = tmp_path / 'empty.py' + dummy_file.write_text("# This file is empty") + mocker.patch('sys.argv', ['script_name', str(dummy_file)]) + mocker.patch('scripts.generate_tests.extract_functions', return_value=[]) + mocker.patch('scripts.generate_tests.generate_all_tests') + + main() + captured = capsys.readouterr() + assert "No functions found to generate tests for" in captured.out + mocker.patch('scripts.generate_tests.extract_functions').assert_called_once_with(str(dummy_file)) + mocker.patch('scripts.generate_tests.generate_all_tests').assert_not_called() + +def test_main_skips_non_python_files(mock_main_dependencies, capsys): + dummy_txt_file = mock_main_dependencies['tmp_path'] / 'README.txt' + dummy_txt_file.write_text("...") + + sys.argv = ['script_name', str(mock_main_dependencies['tmp_path'] / 'app.py'), str(dummy_txt_file)] + + main() + captured = capsys.readouterr() + assert "Analyzing: " + str(dummy_txt_file) not in captured.out + mock_main_dependencies['extract_functions'].assert_called_once_with(str(mock_main_dependencies['tmp_path'] / 'app.py')) # Only called for .py file + +def test_main_skips_test_files(mock_main_dependencies, capsys): + dummy_test_file = mock_main_dependencies['tmp_path'] / 'tests/test_foo.py' + dummy_test_file.parent.mkdir(exist_ok=True) + dummy_test_file.write_text("...") + + sys.argv = ['script_name', str(mock_main_dependencies['tmp_path'] / 'app.py'), str(dummy_test_file)] + + main() + captured = capsys.readouterr() + assert "Analyzing: " + str(dummy_test_file) not in captured.out + mock_main_dependencies['extract_functions'].assert_called_once_with(str(mock_main_dependencies['tmp_path'] / 'app.py')) # Only called for non-test .py file +``` \ No newline at end of file From c501ee231f0defc59bd955c19c678deccda99c03 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:07:37 +1300 Subject: [PATCH 19/43] Updated --- app.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app.py b/app.py index aec33cb3..2389bc71 100644 --- a/app.py +++ b/app.py @@ -20,18 +20,3 @@ def multiply(a,b): """Multiply two integers, a and b""" return a * b -def factorial(n): - """Calculate the factorial of n.""" - - # Handle invalid input - factorial isn't defined for negatives - if n < 0: - raise ValueError( - "Factorial not defined for negative numbers" - ) - - # Base case - stop recursion when n is 0 or 1 - if n <= 1: - return 1 - - # Recursive case - n! = n × (n-1)! - return n * factorial(n-1) \ No newline at end of file From 212b4c5f8b5ad517bae40f3216f9c38cac44179d Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:08:59 +1300 Subject: [PATCH 20/43] Updated --- app.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app.py b/app.py index 2389bc71..aec33cb3 100644 --- a/app.py +++ b/app.py @@ -20,3 +20,18 @@ def multiply(a,b): """Multiply two integers, a and b""" return a * b +def factorial(n): + """Calculate the factorial of n.""" + + # Handle invalid input - factorial isn't defined for negatives + if n < 0: + raise ValueError( + "Factorial not defined for negative numbers" + ) + + # Base case - stop recursion when n is 0 or 1 + if n <= 1: + return 1 + + # Recursive case - n! = n × (n-1)! + return n * factorial(n-1) \ No newline at end of file From 0f9461559e84f6c0309d1084625dbf370acdb30a Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:09:17 +1300 Subject: [PATCH 21/43] Add factorial function --- app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.py b/app.py index aec33cb3..5468dde9 100644 --- a/app.py +++ b/app.py @@ -26,7 +26,7 @@ def factorial(n): # Handle invalid input - factorial isn't defined for negatives if n < 0: raise ValueError( - "Factorial not defined for negative numbers" + "Factorial not defined for negative numbers" ) # Base case - stop recursion when n is 0 or 1 From 5a3bd6397d1f47fc980ac14a5396490f50b9954c Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:10:24 +1300 Subject: [PATCH 22/43] Add factorial function --- tests/test_generated.py | 442 ---------------------------------------- 1 file changed, 442 deletions(-) delete mode 100644 tests/test_generated.py diff --git a/tests/test_generated.py b/tests/test_generated.py deleted file mode 100644 index c952e493..00000000 --- a/tests/test_generated.py +++ /dev/null @@ -1,442 +0,0 @@ -```python -import pytest -import os -import sys -import ast -import time -from unittest import mock - -# Assuming the functions are in 'app.py' and 'scripts/generate_tests.py' -# We need to make sure these modules are importable. -# For testing purposes, we might directly import them or adjust sys.path. -# For this output, I'll assume a structure that allows direct import if run from a specific location, -# or for a test runner that handles module discovery. - -# --- Imports for app.py functions --- -# If app.py is in the current directory, these imports work: -try: - from app import add, is_even, reverse_string, multiply, factorial -except ImportError: - # Fallback for environments where app.py might not be directly importable - # This might happen if running tests from a different directory. - # For a real test setup, you'd ensure app.py is in PYTHONPATH or imported correctly. - # For this exercise, we'll define dummy functions if import fails to allow tests to run structurally. - print("Warning: Could not import functions from app.py. Using dummy implementations for tests.") - def add(a: int, b: int) -> int: return a + b - def is_even(n: int) -> bool: return n % 2 == 0 - def reverse_string(s: str) -> str: return s[::-1] - def multiply(a, b): return a * b - def factorial(n): - if n < 0: raise ValueError("Factorial not defined for negative numbers") - if n <= 1: return 1 - return n * factorial(n - 1) - - -# --- Imports for scripts/generate_tests.py functions --- -# Similarly, adjust import based on your project structure. -try: - from scripts.generate_tests import extract_functions, generate_all_tests, main - # Mocking genai.errors and genai.Client for generate_all_tests - # These imports are needed for type hinting or exception handling within the mocked function - from google.generativeai import Client, errors -except ImportError: - print("Warning: Could not import functions from scripts/generate_tests.py. Using dummy implementations for tests.") - # Dummy implementations for testing structure - def extract_functions(file_path): return [] - def generate_all_tests(all_functions, max_retries=5, initial_delay=60): return "mock_test_code" - def main(): pass - # Dummy classes for mocking purposes if the actual module is not available - class MockClient: - def models(self): - return self - def generate_content(self, model, contents): - raise MockClientError("Mock error") # Default to error for testing retry logic - class MockClientError(Exception): - def __init__(self, message, code=None): - super().__init__(message) - self.code = code - Client = MockClient - errors = mock.MagicMock() - errors.ClientError = MockClientError - - -# --- Tests for app.py functions --- - -def test_add_positive_numbers(): - assert add(2, 3) == 5 - -def test_add_negative_numbers(): - assert add(-5, -7) == -12 - -def test_add_mixed_numbers(): - assert add(10, -4) == 6 - -def test_add_zero(): - assert add(0, 8) == 8 - assert add(5, 0) == 5 - assert add(0, 0) == 0 - -def test_is_even_positive_even(): - assert is_even(4) is True - -def test_is_even_positive_odd(): - assert is_even(7) is False - -def test_is_even_negative_even(): - assert is_even(-2) is True - -def test_is_even_negative_odd(): - assert is_even(-9) is False - -def test_is_even_zero(): - assert is_even(0) is True - -def test_reverse_string_basic(): - assert reverse_string("hello") == "olleh" - -def test_reverse_string_with_spaces(): - assert reverse_string("hello world") == "dlrow olleh" - -def test_reverse_string_empty(): - assert reverse_string("") == "" - -def test_reverse_string_single_character(): - assert reverse_string("a") == "a" - -def test_reverse_string_palindrome(): - assert reverse_string("madam") == "madam" - -def test_multiply_positive_numbers(): - assert multiply(3, 4) == 12 - -def test_multiply_negative_numbers(): - assert multiply(-2, -5) == 10 - -def test_multiply_mixed_signs(): - assert multiply(6, -3) == -18 - assert multiply(-7, 2) == -14 - -def test_multiply_by_zero(): - assert multiply(10, 0) == 0 - assert multiply(0, -5) == 0 - assert multiply(0, 0) == 0 - -def test_multiply_by_one(): - assert multiply(5, 1) == 5 - assert multiply(-8, 1) == -8 - -def test_factorial_zero(): - assert factorial(0) == 1 - -def test_factorial_one(): - assert factorial(1) == 1 - -def test_factorial_positive_number(): - assert factorial(3) == 6 # 3*2*1 - assert factorial(5) == 120 # 5*4*3*2*1 - -def test_factorial_negative_number_raises_error(): - with pytest.raises(ValueError, match="Factorial not defined for negative numbers"): - factorial(-1) - with pytest.raises(ValueError, match="Factorial not defined for negative numbers"): - factorial(-5) - - -# --- Tests for scripts/generate_tests.py functions --- - -@pytest.fixture -def sample_python_file(tmp_path): - """Fixture to create a temporary Python file for testing extract_functions.""" - file_content = """ -import os - -def func_a(arg1, arg2): - \"\"\"Docstring for func_a.\"\"\" - return arg1 + arg2 - -class MyClass: - def method_b(self): - pass - -def _private_func(x): - \"\"\"This is a private function.\"\"\" - return x * 2 - -def func_c(): - \"\"\"No args, simple function.\"\"\" - pass -""" - file_path = tmp_path / "sample_module.py" - file_path.write_text(file_content) - return file_path - -@pytest.fixture -def empty_python_file(tmp_path): - """Fixture for an empty Python file.""" - file_path = tmp_path / "empty_module.py" - file_path.write_text("") - return file_path - -@pytest.fixture -def no_function_python_file(tmp_path): - """Fixture for a Python file with no functions.""" - file_content = """ -import sys - -x = 10 -y = "hello" -""" - file_path = tmp_path / "no_func_module.py" - file_path.write_text(file_content) - return file_path - -def test_extract_functions_basic_case(sample_python_file): - functions = extract_functions(sample_python_file) - assert len(functions) == 3 # func_a, _private_func, func_c - - func_names = [f['name'] for f in functions] - assert "func_a" in func_names - assert "_private_func" in func_names - assert "func_c" in func_names - - func_a_info = next(f for f in functions if f['name'] == 'func_a') - assert func_a_info['args'] == ['arg1', 'arg2'] - assert func_a_info['docstring'] == "Docstring for func_a." - assert "return arg1 + arg2" in func_a_info['source'] - - func_c_info = next(f for f in functions if f['name'] == 'func_c') - assert func_c_info['args'] == [] - assert func_c_info['docstring'] == "No args, simple function." - - -def test_extract_functions_empty_file(empty_python_file): - functions = extract_functions(empty_python_file) - assert len(functions) == 0 - -def test_extract_functions_no_functions_file(no_function_python_file): - functions = extract_functions(no_function_python_file) - assert len(functions) == 0 - -def test_extract_functions_file_not_found(): - with pytest.raises(FileNotFoundError): - extract_functions("non_existent_file.py") - -def test_extract_functions_with_no_docstring(tmp_path): - file_content = """ -def func_no_doc(): - pass -""" - file_path = tmp_path / "no_doc_module.py" - file_path.write_text(file_content) - functions = extract_functions(file_path) - assert len(functions) == 1 - assert functions[0]['name'] == 'func_no_doc' - assert functions[0]['docstring'] == "" # Should be an empty string - -# Mock the genai client for generate_all_tests -@pytest.fixture -def mock_genai_client(mocker): - mock_client = mocker.Mock(spec=Client) - mock_models = mocker.Mock() - mock_generate_content = mocker.Mock() - mock_response = mocker.Mock() - mock_response.text = "Generated test code content" - - mock_generate_content.return_value = mock_response - mock_models.generate_content.return_value = mock_response - mock_client.models.return_value = mock_models - mocker.patch('google.generativeai.Client', return_value=mock_client) - return mock_generate_content - -def test_generate_all_tests_success(mock_genai_client): - mock_functions = [ - ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add two numbers.', 'source': 'def add(a, b): return a + b'}), - ('app.py', {'name': 'is_even', 'args': ['n'], 'docstring': 'Check even.', 'source': 'def is_even(n): return n % 2 == 0'}), - ] - expected_output = "Generated test code content" - - result = generate_all_tests(mock_functions) - assert result == expected_output - mock_genai_client.assert_called_once() - - # Verify prompt content - call_args, _ = mock_genai_client.call_args - prompt = call_args[1]['contents'] # contents argument of generate_content - assert "--- Function from app.py ---" in prompt - assert "Function name: add" in prompt - assert "Arguments: a, b" in prompt - assert "Docstring: Add two numbers." in prompt - assert "Source code:\ndef add(a, b): return a + b" in prompt - assert "Function name: is_even" in prompt - assert "Requirements:" in prompt - assert "Return ONLY the Python test code, no explanations or markdown code blocks." in prompt - -def test_generate_all_tests_rate_limit_retry(mocker, mock_genai_client): - # Simulate a 429 error twice, then success - mock_genai_client.side_effect = [ - errors.ClientError("429 RESOURCE_EXHAUSTED", code=429), - errors.ClientError("429 RESOURCE_EXHAUSTED", code=429), - mocker.Mock(text="Generated test code content after retries") - ] - mocker.patch('time.sleep', return_value=None) # Prevent actual sleep during test - - mock_functions = [ - ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add two numbers.', 'source': 'def add(a, b): return a + b'}), - ] - - result = generate_all_tests(mock_functions, max_retries=3, initial_delay=0.01) - assert result == "Generated test code content after retries" - assert mock_genai_client.call_count == 3 # Two retries + one successful call - # Check that sleep was called (at least once for each retry) - assert mocker.patch('time.sleep').call_count >= 2 - -def test_generate_all_tests_rate_limit_exceeded(mocker, mock_genai_client): - # Simulate rate limit failure for all retries - mock_genai_client.side_effect = errors.ClientError("429 RESOURCE_EXHAUSTED", code=429) - mocker.patch('time.sleep', return_value=None) - - mock_functions = [ - ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add two numbers.', 'source': 'def add(a, b): return a + b'}), - ] - - with pytest.raises(errors.ClientError, match="429 RESOURCE_EXHAUSTED"): - generate_all_tests(mock_functions, max_retries=2, initial_delay=0.01) - assert mock_genai_client.call_count == 2 # Max retries attempted - -# Mock various components for the main function -@pytest.fixture -def mock_main_dependencies(mocker, tmp_path): - # Mock sys.argv - mocker.patch('sys.argv', ['script_name', str(tmp_path / 'app.py'), str(tmp_path / 'scripts/generate_tests.py')]) - - # Mock os.makedirs - mocker.patch('os.makedirs') - - # Mock extract_functions to return a controlled list of functions - mock_extract_functions = mocker.patch('scripts.generate_tests.extract_functions') - mock_extract_functions.side_effect = [ - # Return for 'app.py' - [ - {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add.', 'source': 'def add(a,b):pass'}, - {'name': 'is_even', 'args': ['n'], 'docstring': 'Even.', 'source': 'def is_even(n):pass'}, - {'name': '_private_func', 'args': [], 'docstring': 'Private.', 'source': 'def _private_func():pass'}, # Should be filtered out - ], - # Return for 'scripts/generate_tests.py' - [ - {'name': 'extract_functions', 'args': ['file_path'], 'docstring': 'Extract.', 'source': 'def extract_functions(f):pass'} - ] - ] - - # Mock generate_all_tests to return a dummy test string - mock_generate_all_tests = mocker.patch('scripts.generate_tests.generate_all_tests', return_value=""" -# Generated test content -def test_add_generated(): - assert 1 + 1 == 2 -""") - - # Mock open for writing the test file - mock_open = mocker.mock_open() - mocker.patch('builtins.open', mock_open) - - return { - 'sys_argv': mocker.patch('sys.argv'), - 'os_makedirs': mocker.patch('os.makedirs'), - 'extract_functions': mock_extract_functions, - 'generate_all_tests': mock_generate_all_tests, - 'mock_open': mock_open, - 'tmp_path': tmp_path - } - - -def test_main_generates_tests_for_multiple_files(mock_main_dependencies, capsys): - # Create dummy files for sys.argv to point to - mock_main_dependencies['tmp_path'].joinpath('app.py').write_text("...") - mock_main_dependencies['tmp_path'].joinpath('scripts/generate_tests.py').write_text("...") - - # Set sys.argv to include the dummy file paths - sys.argv = ['script_name', str(mock_main_dependencies['tmp_path'] / 'app.py'), str(mock_main_dependencies['tmp_path'] / 'scripts/generate_tests.py')] - - main() - - # Assertions for main's behavior - mock_main_dependencies['os_makedirs'].assert_called_once_with('tests', exist_ok=True) - - # Check extract_functions calls (one per file) - assert mock_main_dependencies['extract_functions'].call_count == 2 - mock_main_dependencies['extract_functions'].assert_any_call(str(mock_main_dependencies['tmp_path'] / 'app.py')) - mock_main_dependencies['extract_functions'].assert_any_call(str(mock_main_dependencies['tmp_path'] / 'scripts/generate_tests.py')) - - # Check generate_all_tests call - mock_main_dependencies['generate_all_tests'].assert_called_once() - args, _ = mock_main_dependencies['generate_all_tests'].call_args - # It should have collected 3 functions: add, is_even, extract_functions (private _func filtered) - assert len(args[0]) == 3 - assert ('app.py', {'name': 'add', 'args': ['a', 'b'], 'docstring': 'Add.', 'source': 'def add(a,b):pass'}) in args[0] - assert ('app.py', {'name': 'is_even', 'args': ['n'], 'docstring': 'Even.', 'source': 'def is_even(n):pass'}) in args[0] - assert ('scripts/generate_tests.py', {'name': 'extract_functions', 'args': ['file_path'], 'docstring': 'Extract.', 'source': 'def extract_functions(f):pass'}) in args[0] - - - # Check file writing - mock_main_dependencies['mock_open'].assert_called_once_with('tests/test_generated.py', 'w') - mock_main_dependencies['mock_open']().write.assert_called_once_with(""" -# Generated test content -def test_add_generated(): - assert 1 + 1 == 2 -""") - - # Check print statements - captured = capsys.readouterr() - assert "Analyzing:" in captured.out - assert "Found function: add" in captured.out - assert "Found function: is_even" in captured.out - assert "Found function: extract_functions" in captured.out - assert "Generating tests for 3 functions..." in captured.out - assert "Generated tests written to: tests/test_generated.py" in captured.out - -def test_main_no_files_provided(mocker, capsys): - mocker.patch('sys.argv', ['script_name']) - mocker.patch('scripts.generate_tests.extract_functions') - mocker.patch('scripts.generate_tests.generate_all_tests') - - main() - captured = capsys.readouterr() - assert "No Python files provided for test generation" in captured.out - mocker.patch('scripts.generate_tests.extract_functions').assert_not_called() - mocker.patch('scripts.generate_tests.generate_all_tests').assert_not_called() - -def test_main_no_functions_found(mocker, capsys, tmp_path): - dummy_file = tmp_path / 'empty.py' - dummy_file.write_text("# This file is empty") - mocker.patch('sys.argv', ['script_name', str(dummy_file)]) - mocker.patch('scripts.generate_tests.extract_functions', return_value=[]) - mocker.patch('scripts.generate_tests.generate_all_tests') - - main() - captured = capsys.readouterr() - assert "No functions found to generate tests for" in captured.out - mocker.patch('scripts.generate_tests.extract_functions').assert_called_once_with(str(dummy_file)) - mocker.patch('scripts.generate_tests.generate_all_tests').assert_not_called() - -def test_main_skips_non_python_files(mock_main_dependencies, capsys): - dummy_txt_file = mock_main_dependencies['tmp_path'] / 'README.txt' - dummy_txt_file.write_text("...") - - sys.argv = ['script_name', str(mock_main_dependencies['tmp_path'] / 'app.py'), str(dummy_txt_file)] - - main() - captured = capsys.readouterr() - assert "Analyzing: " + str(dummy_txt_file) not in captured.out - mock_main_dependencies['extract_functions'].assert_called_once_with(str(mock_main_dependencies['tmp_path'] / 'app.py')) # Only called for .py file - -def test_main_skips_test_files(mock_main_dependencies, capsys): - dummy_test_file = mock_main_dependencies['tmp_path'] / 'tests/test_foo.py' - dummy_test_file.parent.mkdir(exist_ok=True) - dummy_test_file.write_text("...") - - sys.argv = ['script_name', str(mock_main_dependencies['tmp_path'] / 'app.py'), str(dummy_test_file)] - - main() - captured = capsys.readouterr() - assert "Analyzing: " + str(dummy_test_file) not in captured.out - mock_main_dependencies['extract_functions'].assert_called_once_with(str(mock_main_dependencies['tmp_path'] / 'app.py')) # Only called for non-test .py file -``` \ No newline at end of file From 0fdaeaa339f2f28fa5b41eefc3e98892568873ab Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:10:32 +1300 Subject: [PATCH 23/43] Add factorial function --- app.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app.py b/app.py index 5468dde9..2389bc71 100644 --- a/app.py +++ b/app.py @@ -20,18 +20,3 @@ def multiply(a,b): """Multiply two integers, a and b""" return a * b -def factorial(n): - """Calculate the factorial of n.""" - - # Handle invalid input - factorial isn't defined for negatives - if n < 0: - raise ValueError( - "Factorial not defined for negative numbers" - ) - - # Base case - stop recursion when n is 0 or 1 - if n <= 1: - return 1 - - # Recursive case - n! = n × (n-1)! - return n * factorial(n-1) \ No newline at end of file From 3e258f7fdded21ee27fcafff15b96531901fd685 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:11:34 +1300 Subject: [PATCH 24/43] Add factorial function --- app.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app.py b/app.py index 2389bc71..aec33cb3 100644 --- a/app.py +++ b/app.py @@ -20,3 +20,18 @@ def multiply(a,b): """Multiply two integers, a and b""" return a * b +def factorial(n): + """Calculate the factorial of n.""" + + # Handle invalid input - factorial isn't defined for negatives + if n < 0: + raise ValueError( + "Factorial not defined for negative numbers" + ) + + # Base case - stop recursion when n is 0 or 1 + if n <= 1: + return 1 + + # Recursive case - n! = n × (n-1)! + return n * factorial(n-1) \ No newline at end of file From bf910d1ed5d5e7de956612828e244fe4ff6137d9 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:15:03 +1300 Subject: [PATCH 25/43] Updated --- app.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app.py b/app.py index aec33cb3..2389bc71 100644 --- a/app.py +++ b/app.py @@ -20,18 +20,3 @@ def multiply(a,b): """Multiply two integers, a and b""" return a * b -def factorial(n): - """Calculate the factorial of n.""" - - # Handle invalid input - factorial isn't defined for negatives - if n < 0: - raise ValueError( - "Factorial not defined for negative numbers" - ) - - # Base case - stop recursion when n is 0 or 1 - if n <= 1: - return 1 - - # Recursive case - n! = n × (n-1)! - return n * factorial(n-1) \ No newline at end of file From 37db13cb6d54aab0927bf018efbdb6fa8e94d97d Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:15:32 +1300 Subject: [PATCH 26/43] Add factorial function --- app.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app.py b/app.py index 2389bc71..aec33cb3 100644 --- a/app.py +++ b/app.py @@ -20,3 +20,18 @@ def multiply(a,b): """Multiply two integers, a and b""" return a * b +def factorial(n): + """Calculate the factorial of n.""" + + # Handle invalid input - factorial isn't defined for negatives + if n < 0: + raise ValueError( + "Factorial not defined for negative numbers" + ) + + # Base case - stop recursion when n is 0 or 1 + if n <= 1: + return 1 + + # Recursive case - n! = n × (n-1)! + return n * factorial(n-1) \ No newline at end of file From 5c9c08aba34959bf875d509ecefd51dd4afad274 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 09:16:14 +0000 Subject: [PATCH 27/43] Auto-generate tests for new code [skip ci] --- tests/test_generated.py | 75 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_generated.py diff --git a/tests/test_generated.py b/tests/test_generated.py new file mode 100644 index 00000000..0302f9f6 --- /dev/null +++ b/tests/test_generated.py @@ -0,0 +1,75 @@ +from app import add, is_even, reverse_string, multiply, factorial +import pytest + +def test_add_positive_numbers(): + assert add(2, 3) == 5 + +def test_add_negative_numbers(): + assert add(-1, -4) == -5 + +def test_add_positive_and_negative(): + assert add(5, -2) == 3 + +def test_add_with_zero(): + assert add(0, 7) == 7 + +def test_is_even_positive_even(): + assert is_even(4) is True + +def test_is_even_positive_odd(): + assert is_even(7) is False + +def test_is_even_zero(): + assert is_even(0) is True + +def test_is_even_negative_even(): + assert is_even(-2) is True + +def test_is_even_negative_odd(): + assert is_even(-5) is False + +def test_reverse_string_normal_case(): + assert reverse_string("hello") == "olleh" + +def test_reverse_string_palindrome(): + assert reverse_string("madam") == "madam" + +def test_reverse_string_with_spaces(): + assert reverse_string("hello world") == "dlrow olleh" + +def test_reverse_string_empty(): + assert reverse_string("") == "" + +def test_reverse_string_single_character(): + assert reverse_string("a") == "a" + +def test_multiply_positive_numbers(): + assert multiply(2, 3) == 6 + +def test_multiply_one_positive_one_negative(): + assert multiply(5, -2) == -10 + +def test_multiply_two_negative_numbers(): + assert multiply(-3, -4) == 12 + +def test_multiply_by_zero(): + assert multiply(0, 7) == 0 + +def test_multiply_by_one(): + assert multiply(1, 10) == 10 + +def test_factorial_positive_number(): + assert factorial(5) == 120 + +def test_factorial_zero(): + assert factorial(0) == 1 + +def test_factorial_one(): + assert factorial(1) == 1 + +def test_factorial_negative_number_raises_error(): + with pytest.raises(ValueError, match="Factorial not defined for negative numbers"): + factorial(-1) + +def test_factorial_larger_number(): + assert factorial(7) == 5040 \ No newline at end of file From f5502b553ccb536ba31c1a90d407ef71eb5a426d Mon Sep 17 00:00:00 2001 From: CARWHO Date: Tue, 27 Jan 2026 22:43:19 +1300 Subject: [PATCH 28/43] Add palindrome checker (no tests) --- more_utils.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 more_utils.py diff --git a/more_utils.py b/more_utils.py new file mode 100644 index 00000000..074da6df --- /dev/null +++ b/more_utils.py @@ -0,0 +1,4 @@ +def is_palindrome(text): + """Check if a string is a palindrome.""" + cleaned = text.lower().replace(" ", "") + return cleaned == cleaned[::-1] From 1eb0a946cd1f0d5a7060982334a90e2d0e5727c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Jan 2026 09:44:04 +0000 Subject: [PATCH 29/43] Auto-generate tests for new code [skip ci] --- tests/test_generated.py | 92 +++++++++++------------------------------ 1 file changed, 25 insertions(+), 67 deletions(-) diff --git a/tests/test_generated.py b/tests/test_generated.py index 0302f9f6..c2ff2599 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -1,75 +1,33 @@ -from app import add, is_even, reverse_string, multiply, factorial +```python import pytest +from more_utils import is_palindrome -def test_add_positive_numbers(): - assert add(2, 3) == 5 +def test_is_palindrome_simple_true(): + """Test with a simple, lowercase palindrome.""" + assert is_palindrome("madam") is True -def test_add_negative_numbers(): - assert add(-1, -4) == -5 +def test_is_palindrome_with_spaces_and_case(): + """Test with a palindrome that includes spaces and mixed case.""" + assert is_palindrome("Race car") is True -def test_add_positive_and_negative(): - assert add(5, -2) == 3 +def test_is_palindrome_simple_false(): + """Test with a simple non-palindrome string.""" + assert is_palindrome("hello") is False -def test_add_with_zero(): - assert add(0, 7) == 7 +def test_is_palindrome_empty_string(): + """Test the edge case of an empty string.""" + assert is_palindrome("") is True -def test_is_even_positive_even(): - assert is_even(4) is True +def test_is_palindrome_single_character(): + """Test the edge case of a single character string.""" + assert is_palindrome("a") is True -def test_is_even_positive_odd(): - assert is_even(7) is False +def test_is_palindrome_only_spaces(): + """Test a string containing only spaces, which should be a palindrome.""" + assert is_palindrome(" ") is True -def test_is_even_zero(): - assert is_even(0) is True - -def test_is_even_negative_even(): - assert is_even(-2) is True - -def test_is_even_negative_odd(): - assert is_even(-5) is False - -def test_reverse_string_normal_case(): - assert reverse_string("hello") == "olleh" - -def test_reverse_string_palindrome(): - assert reverse_string("madam") == "madam" - -def test_reverse_string_with_spaces(): - assert reverse_string("hello world") == "dlrow olleh" - -def test_reverse_string_empty(): - assert reverse_string("") == "" - -def test_reverse_string_single_character(): - assert reverse_string("a") == "a" - -def test_multiply_positive_numbers(): - assert multiply(2, 3) == 6 - -def test_multiply_one_positive_one_negative(): - assert multiply(5, -2) == -10 - -def test_multiply_two_negative_numbers(): - assert multiply(-3, -4) == 12 - -def test_multiply_by_zero(): - assert multiply(0, 7) == 0 - -def test_multiply_by_one(): - assert multiply(1, 10) == 10 - -def test_factorial_positive_number(): - assert factorial(5) == 120 - -def test_factorial_zero(): - assert factorial(0) == 1 - -def test_factorial_one(): - assert factorial(1) == 1 - -def test_factorial_negative_number_raises_error(): - with pytest.raises(ValueError, match="Factorial not defined for negative numbers"): - factorial(-1) - -def test_factorial_larger_number(): - assert factorial(7) == 5040 \ No newline at end of file +def test_is_palindrome_with_none_input_raises_error(): + """Test the edge case of None input, which should raise an AttributeError.""" + with pytest.raises(AttributeError): + is_palindrome(None) +``` \ No newline at end of file From 6f77efe9952f390914c059d6f50e9c3c0bfeaaf0 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:10:47 +1300 Subject: [PATCH 30/43] Add stale code detector with weekly workflow --- .github/workflows/stale-code.yml | 49 +++++++++++ scripts/find_stale.py | 144 +++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 .github/workflows/stale-code.yml create mode 100644 scripts/find_stale.py diff --git a/.github/workflows/stale-code.yml b/.github/workflows/stale-code.yml new file mode 100644 index 00000000..d9efdd66 --- /dev/null +++ b/.github/workflows/stale-code.yml @@ -0,0 +1,49 @@ +name: Stale Code Detector + +on: + # Run every Monday at 9:00 AM UTC + schedule: + - cron: '0 9 * * 1' + # Allow manual triggering for testing + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + detect-stale-code: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install google-genai + + - name: Run stale code detector + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + python scripts/find_stale.py || true + + - name: Create GitHub Issue + if: hashFiles('stale_code_report.md') != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Check if report has findings (not just "No dead code detected") + if grep -q "Found .* potential issues" stale_code_report.md; then + gh issue create \ + --title "Weekly Stale Code Report - $(date +%Y-%m-%d)" \ + --body-file stale_code_report.md + else + echo "No stale code findings to report" + fi diff --git a/scripts/find_stale.py b/scripts/find_stale.py new file mode 100644 index 00000000..c28e87db --- /dev/null +++ b/scripts/find_stale.py @@ -0,0 +1,144 @@ +import os +import json +import time +from pathlib import Path +from google import genai + +# Create a Gemini client, passing in the API key from environment variables +client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY")) + +def get_python_files(repo_path: str) -> list[str]: + """Find all Python files in the repository.""" + python_files = [] + + for path in Path(repo_path).rglob("*.py"): + # Skip virtual environments and common directories to ignore + if any(part in path.parts for part in ["venv", ".venv", "node_modules", "__pycache__", ".git"]): + continue + python_files.append(str(path)) + + + return python_files + +def read_file_content(file_path: str) -> str: + """Read and return the content of a file.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + except Exception as e: + return f"Error reading file: {e}" + +def analyze_code(code_content: str, file_path: str) -> dict: + """Send code to Gemini for dead code analysis.""" + prompt = f"""Analyze the following Python code and identify any dead code. + +Look for: +1. Unused functions (defined but never called within this file) +2. Dead imports (imported but never used) +3. Unreachable code (code after return statements, etc.) + +File: {file_path} + +Code: + +{code_content} + + +Respond in JSON format with this structure: +{{ + "findings": [ + {{ + "type": "unused_function" | "dead_import" | "unreachable_code", + "name": "name of the function/import/code", + "line": line_number, + "description": "brief description of why this is dead code" + }} + ] +}} + +If no dead code is found, return: {{"findings": []}} +Only return the JSON, no additional text.""" + + try: + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt + ) + response_text = response.text.strip() + if response_text.startswith("`" * 3): + response_text = response_text.split("\n", 1)[1] + response_text = response_text.rsplit("`" * 3, 1)[0] + return json.loads(response_text) + except Exception as e: + return {"error": str(e), "findings": []} + +def format_findings_markdown(all_findings: list[dict]) -> str: + """Format all findings as markdown for a GitHub Issue.""" + if not all_findings: + return "## Stale Code Report\n\nNo dead code detected in this scan." + + markdown = "## Stale Code Report\n\n" + markdown += f"Found {len(all_findings)} potential issues:\n\n" + + by_type = {} + for finding in all_findings: + finding_type = finding.get("type", "unknown") + if finding_type not in by_type: + by_type[finding_type] = [] + by_type[finding_type].append(finding) + + type_labels = { + "unused_function": "Unused Functions", + "dead_import": "Dead Imports", + "unreachable_code": "Unreachable Code" + } + + for finding_type, findings in by_type.items(): + label = type_labels.get(finding_type, finding_type) + markdown += f"### {label}\n\n" + for f in findings: + markdown += f"- **{f.get('name', 'Unknown')}** ({f.get('file', 'unknown')}:{f.get('line', '?')})\n" + markdown += f" - {f.get('description', 'No description')}\n\n" + + return markdown + +def main(): + """Main function to scan repository and report findings.""" + repo_path = os.environ.get("GITHUB_WORKSPACE", ".") + + print(f"Scanning repository: {repo_path}") + + python_files = get_python_files(repo_path) + print(f"Found {len(python_files)} Python files") + + all_findings = [] + for i, file_path in enumerate(python_files): + print(f"Analyzing: {file_path}") + content = read_file_content(file_path) + if content.startswith("Error"): + print(f" Skipping due to read error") + continue + + result = analyze_code(content, file_path) + + if "error" in result: + print(f" Analysis error: {result['error']}") + continue + + for finding in result.get("findings", []): + finding["file"] = file_path + all_findings.append(finding) + + markdown_report = format_findings_as_markdown(all_findings) + print("\n" + "=" * 50) + print(markdown_report) + + with open("stale_code_report.md", "w") as f: + f.write(markdown_report) + + print(f"\nReport saved to stale_code_report.md") + return all_findings + +if __name__ == "__main__": + findings = main() + exit(1 if findings else 0) From 28aaacff3d6868e4e2308bf4a87ebfd1a15a3c97 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:15:12 +1300 Subject: [PATCH 31/43] Add stale code detector with weekly workflow --- tests/test_generated.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_generated.py b/tests/test_generated.py index c2ff2599..598d7941 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -1,4 +1,3 @@ -```python import pytest from more_utils import is_palindrome @@ -29,5 +28,4 @@ def test_is_palindrome_only_spaces(): def test_is_palindrome_with_none_input_raises_error(): """Test the edge case of None input, which should raise an AttributeError.""" with pytest.raises(AttributeError): - is_palindrome(None) -``` \ No newline at end of file + is_palindrome(None) \ No newline at end of file From e87e1dc2632a5d7e3f3fdd188de4dbe75de8c134 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:16:51 +1300 Subject: [PATCH 32/43] Add stale code detector with weekly workflow --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c38f5010..cac767a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,10 @@ version = "0.1.0" description = "A starter project for learning GitHub Actions CI/CD" readme = "README.md" requires-python = ">=3.9" -license = {text = "MIT"} +license = "MIT" + +[tool.setuptools] +py-modules = ["app", "more_utils"] authors = [ {name = "Your Name", email = "you@example.com"} ] From c95a92562c17d4333011944184a4cdcb6e52b21c Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:18:33 +1300 Subject: [PATCH 33/43] Add stale code detector with weekly workflow --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cac767a3..bf74b12c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,13 @@ description = "A starter project for learning GitHub Actions CI/CD" readme = "README.md" requires-python = ">=3.9" license = "MIT" - -[tool.setuptools] -py-modules = ["app", "more_utils"] authors = [ {name = "Your Name", email = "you@example.com"} ] +[tool.setuptools] +py-modules = ["app", "more_utils"] + [project.optional-dependencies] dev = [ "pytest>=7.0.0", From 7386c40b2c1696a41a5a0556ec2df5f7e73e15c7 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:28:42 +1300 Subject: [PATCH 34/43] Added delay --- scripts/find_stale.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/find_stale.py b/scripts/find_stale.py index c28e87db..f4e7e0db 100644 --- a/scripts/find_stale.py +++ b/scripts/find_stale.py @@ -121,6 +121,8 @@ def main(): result = analyze_code(content, file_path) + time.delay(12) + if "error" in result: print(f" Analysis error: {result['error']}") continue From 874c5379823ae6f4e06eaa6d064b8d474b2c3bd7 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:34:55 +1300 Subject: [PATCH 35/43] Added delay --- scripts/find_stale.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/find_stale.py b/scripts/find_stale.py index f4e7e0db..2da542d5 100644 --- a/scripts/find_stale.py +++ b/scripts/find_stale.py @@ -121,7 +121,7 @@ def main(): result = analyze_code(content, file_path) - time.delay(12) + time.sleep(12) if "error" in result: print(f" Analysis error: {result['error']}") @@ -131,7 +131,7 @@ def main(): finding["file"] = file_path all_findings.append(finding) - markdown_report = format_findings_as_markdown(all_findings) + markdown_report = format_findings_markdown(all_findings) print("\n" + "=" * 50) print(markdown_report) From 9465298fe90141f5d508c4fd50322d0d3580273b Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:39:54 +1300 Subject: [PATCH 36/43] Added delay --- scripts/find_stale.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/find_stale.py b/scripts/find_stale.py index 2da542d5..6cb0c70c 100644 --- a/scripts/find_stale.py +++ b/scripts/find_stale.py @@ -119,9 +119,9 @@ def main(): print(f" Skipping due to read error") continue - result = analyze_code(content, file_path) - time.sleep(12) + result = analyze_code(content, file_path) + if "error" in result: print(f" Analysis error: {result['error']}") From 9931e28435c97f2682f1156a99f069f25b7d349b Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:47:20 +1300 Subject: [PATCH 37/43] Force sync From 78e34f723e153684eae6376231e891c25fa45ce0 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 20:50:15 +1300 Subject: [PATCH 38/43] Added delay --- scripts/find_stale.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/find_stale.py b/scripts/find_stale.py index 6cb0c70c..06bb52d2 100644 --- a/scripts/find_stale.py +++ b/scripts/find_stale.py @@ -119,7 +119,7 @@ def main(): print(f" Skipping due to read error") continue - time.sleep(12) + time.sleep(35) result = analyze_code(content, file_path) From ebd52890836644f4d4a42b9eeb9a137979f6f90b Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 21:27:59 +1300 Subject: [PATCH 39/43] Add time estimates to stale code detector --- scripts/find_stale.py | 88 +++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/scripts/find_stale.py b/scripts/find_stale.py index 06bb52d2..469a25d3 100644 --- a/scripts/find_stale.py +++ b/scripts/find_stale.py @@ -32,32 +32,40 @@ def analyze_code(code_content: str, file_path: str) -> dict: """Send code to Gemini for dead code analysis.""" prompt = f"""Analyze the following Python code and identify any dead code. -Look for: -1. Unused functions (defined but never called within this file) -2. Dead imports (imported but never used) -3. Unreachable code (code after return statements, etc.) + Look for: + 1. Unused functions (defined but never called within this file) + 2. Dead imports (imported but never used) + 3. Unreachable code (code after return statements, etc.) -File: {file_path} + # Add time estimation guidelines here + # Simple fixes: ~2-5 min, Medium: ~10-15 min, Complex: ~30+ min + For each finding, estimate the cleanup time required. Simple fixes -> 2-5 mins, Medium fixes -> 10-15 mins, Complex fixes -> 30+ mins -Code: + File: {file_path} -{code_content} + Code: -Respond in JSON format with this structure: -{{ - "findings": [ - {{ - "type": "unused_function" | "dead_import" | "unreachable_code", - "name": "name of the function/import/code", - "line": line_number, - "description": "brief description of why this is dead code" - }} - ] -}} + {code_content} + + Respond in JSON format with this structure: + {{ + "findings": [ + {{ + "type": "unused_function" | "dead_import" | "unreachable_code", + "name": "name of the function/import/code", + "line": line_number, + "description": "brief description of why this is dead code", + # Add the new fields for time estimates + "estimated_minutes": number, "complexity": simple | medium | complex, "reasoning": explanation of the issue + }} + ] + }} + + If no dead code is found, return: {{"findings": []}} + + Only return the JSON, no additional text.""" -If no dead code is found, return: {{"findings": []}} -Only return the JSON, no additional text.""" try: response = client.models.generate_content( @@ -72,33 +80,21 @@ def analyze_code(code_content: str, file_path: str) -> dict: except Exception as e: return {"error": str(e), "findings": []} -def format_findings_markdown(all_findings: list[dict]) -> str: - """Format all findings as markdown for a GitHub Issue.""" +def format_findings_as_markdown(all_findings: list[dict]) -> str: + """Format all findings as markdown with time estimates.""" if not all_findings: - return "## Stale Code Report\n\nNo dead code detected in this scan." + return "No dead code detected in this scan." + + # Calculate total cleanup time + # Hint: sum up all estimated_minutes, defaulting to 0 if missing + total_minutes = sum(f.get("estimated_minutes", 0) for f in all_findings) markdown = "## Stale Code Report\n\n" - markdown += f"Found {len(all_findings)} potential issues:\n\n" - - by_type = {} - for finding in all_findings: - finding_type = finding.get("type", "unknown") - if finding_type not in by_type: - by_type[finding_type] = [] - by_type[finding_type].append(finding) - - type_labels = { - "unused_function": "Unused Functions", - "dead_import": "Dead Imports", - "unreachable_code": "Unreachable Code" - } - - for finding_type, findings in by_type.items(): - label = type_labels.get(finding_type, finding_type) - markdown += f"### {label}\n\n" - for f in findings: - markdown += f"- **{f.get('name', 'Unknown')}** ({f.get('file', 'unknown')}:{f.get('line', '?')})\n" - markdown += f" - {f.get('description', 'No description')}\n\n" + markdown += f"**{len(all_findings)}** issues found ~**{total_minutes} minutes** total cleanup\n\n" + + for f in all_findings: + mins = f.get("estimated_minutes", "?") + markdown += f"- **{f.get('name')}** (~{mins} min) - {f.get('description')}\n" return markdown @@ -119,7 +115,7 @@ def main(): print(f" Skipping due to read error") continue - time.sleep(35) + time.sleep(18) result = analyze_code(content, file_path) @@ -131,7 +127,7 @@ def main(): finding["file"] = file_path all_findings.append(finding) - markdown_report = format_findings_markdown(all_findings) + markdown_report = format_findings_as_markdown(all_findings) print("\n" + "=" * 50) print(markdown_report) From 42e9495b3b0fd2b212432d0ae0a3421a857cb881 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 21:35:18 +1300 Subject: [PATCH 40/43] Add time estimates to stale code detector --- .github/workflows/stale-code.yml | 2 +- stale_code_report.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 stale_code_report.md diff --git a/.github/workflows/stale-code.yml b/.github/workflows/stale-code.yml index d9efdd66..85c09b79 100644 --- a/.github/workflows/stale-code.yml +++ b/.github/workflows/stale-code.yml @@ -40,7 +40,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Check if report has findings (not just "No dead code detected") - if grep -q "Found .* potential issues" stale_code_report.md; then + if grep -q "issues found" stale_code_report.md; then gh issue create \ --title "Weekly Stale Code Report - $(date +%Y-%m-%d)" \ --body-file stale_code_report.md diff --git a/stale_code_report.md b/stale_code_report.md new file mode 100644 index 00000000..d24ca5ce --- /dev/null +++ b/stale_code_report.md @@ -0,0 +1,10 @@ +## Stale Code Report + +**6** issues found ~**12 minutes** total cleanup + +- **add** (~2 min) - Function 'add' is defined but never called within this file. +- **is_even** (~2 min) - Function 'is_even' is defined but never called within this file. +- **reverse_string** (~2 min) - Function 'reverse_string' is defined but never called within this file. +- **multiply** (~2 min) - Function 'multiply' is defined but never called within this file. +- **factorial** (~2 min) - Function 'factorial' is defined but never called from a top-level scope within this file. +- **is_palindrome** (~2 min) - The function 'is_palindrome' is defined but never called within this file. From 9d12345f9f94163995494ba23b495c169e16e602 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Wed, 28 Jan 2026 21:48:36 +1300 Subject: [PATCH 41/43] Fix hashFiles condition - evaluate at runtime --- .github/workflows/stale-code.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale-code.yml b/.github/workflows/stale-code.yml index 85c09b79..3dea5920 100644 --- a/.github/workflows/stale-code.yml +++ b/.github/workflows/stale-code.yml @@ -35,12 +35,11 @@ jobs: python scripts/find_stale.py || true - name: Create GitHub Issue - if: hashFiles('stale_code_report.md') != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Check if report has findings (not just "No dead code detected") - if grep -q "issues found" stale_code_report.md; then + # Check if report exists and has findings + if [ -f stale_code_report.md ] && grep -q "issues found" stale_code_report.md; then gh issue create \ --title "Weekly Stale Code Report - $(date +%Y-%m-%d)" \ --body-file stale_code_report.md From ea0ff026d21f291ce5d1018671bc2da1f98bf0c9 Mon Sep 17 00:00:00 2001 From: CARWHO Date: Thu, 29 Jan 2026 14:12:54 +1300 Subject: [PATCH 42/43] Updated --- more_utils.py | 13 +++++++++---- scripts/find_stale.py | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/more_utils.py b/more_utils.py index 074da6df..594b633a 100644 --- a/more_utils.py +++ b/more_utils.py @@ -1,4 +1,9 @@ -def is_palindrome(text): - """Check if a string is a palindrome.""" - cleaned = text.lower().replace(" ", "") - return cleaned == cleaned[::-1] +import random +import string + +def generate_password(length=12, include_symbols=True): + """Generate a random password with specified length.""" + characters = string.ascii_letters + string.digits + if include_symbols: + characters += string.punctuation + return ''.join(random.choice(characters) for _ in range(length)) diff --git a/scripts/find_stale.py b/scripts/find_stale.py index 469a25d3..12c7b075 100644 --- a/scripts/find_stale.py +++ b/scripts/find_stale.py @@ -28,6 +28,7 @@ def read_file_content(file_path: str) -> str: except Exception as e: return f"Error reading file: {e}" + def analyze_code(code_content: str, file_path: str) -> dict: """Send code to Gemini for dead code analysis.""" prompt = f"""Analyze the following Python code and identify any dead code. From eb74f970dbc45d45bd493ca170b44971ad78896f Mon Sep 17 00:00:00 2001 From: CARWHO Date: Thu, 29 Jan 2026 14:18:33 +1300 Subject: [PATCH 43/43] Updated --- tests/test_generated.py | 56 ++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/tests/test_generated.py b/tests/test_generated.py index 598d7941..96bdc1ef 100644 --- a/tests/test_generated.py +++ b/tests/test_generated.py @@ -1,31 +1,41 @@ import pytest -from more_utils import is_palindrome +import string +from more_utils import generate_password -def test_is_palindrome_simple_true(): - """Test with a simple, lowercase palindrome.""" - assert is_palindrome("madam") is True +def test_generate_password_default_length(): + """Test that default password has length 12.""" + password = generate_password() + assert len(password) == 12 -def test_is_palindrome_with_spaces_and_case(): - """Test with a palindrome that includes spaces and mixed case.""" - assert is_palindrome("Race car") is True +def test_generate_password_custom_length(): + """Test password generation with custom length.""" + password = generate_password(length=8) + assert len(password) == 8 -def test_is_palindrome_simple_false(): - """Test with a simple non-palindrome string.""" - assert is_palindrome("hello") is False +def test_generate_password_with_symbols(): + """Test that password includes symbols when include_symbols is True.""" + password = generate_password(length=50, include_symbols=True) + assert any(c in string.punctuation for c in password) -def test_is_palindrome_empty_string(): - """Test the edge case of an empty string.""" - assert is_palindrome("") is True +def test_generate_password_without_symbols(): + """Test that password excludes symbols when include_symbols is False.""" + password = generate_password(length=50, include_symbols=False) + assert all(c in string.ascii_letters + string.digits for c in password) -def test_is_palindrome_single_character(): - """Test the edge case of a single character string.""" - assert is_palindrome("a") is True +def test_generate_password_contains_letters(): + """Test that generated password contains letters.""" + password = generate_password(length=20) + assert any(c in string.ascii_letters for c in password) -def test_is_palindrome_only_spaces(): - """Test a string containing only spaces, which should be a palindrome.""" - assert is_palindrome(" ") is True +def test_generate_password_contains_digits(): + """Test that generated password contains digits.""" + password = generate_password(length=20) + assert any(c in string.digits for c in password) -def test_is_palindrome_with_none_input_raises_error(): - """Test the edge case of None input, which should raise an AttributeError.""" - with pytest.raises(AttributeError): - is_palindrome(None) \ No newline at end of file +def test_generate_password_different_each_time(): + """Test that passwords are different when generated multiple times.""" + password1 = generate_password(length=20) + password2 = generate_password(length=20) + # While theoretically they could be the same, it's extremely unlikely + # We'll check that at least one character differs + assert password1 != password2 or len(set(password1)) > 1 \ No newline at end of file