Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2267,16 +2267,25 @@ def _fetch_if_valid(url: str):
if existing_folders is None:
return False

if not no_delete:
deletion_occurred = False
# Identify folders to delete and folders to keep (scan)
folders_to_delete = []
folders_to_scan = existing_folders.copy()

# Identify folders to delete
folders_to_delete = []
if not no_delete:
for folder_data in folder_data_list:
name = folder_data["group"]["group"].strip()
if name in existing_folders:
folders_to_delete.append((name, existing_folders[name]))
if name in folders_to_scan:
del folders_to_scan[name]
Comment on lines +2270 to +2280

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for folders_to_delete and folders_to_scan is duplicated inside and outside the if not no_delete: block. This can lead to confusion and potential bugs if no_delete is true, as folders_to_delete would be empty, but folders_to_scan would still be a copy of existing_folders and not reflect what's actually being scanned. The folders_to_scan should only be modified if no_delete is false.

Suggested change
# Identify folders to delete and folders to keep (scan)
folders_to_delete = []
folders_to_scan = existing_folders.copy()
# Identify folders to delete
folders_to_delete = []
if not no_delete:
for folder_data in folder_data_list:
name = folder_data["group"]["group"].strip()
if name in existing_folders:
folders_to_delete.append((name, existing_folders[name]))
if name in folders_to_scan:
del folders_to_scan[name]
folders_to_delete = []
folders_to_scan = existing_folders.copy()
if not no_delete:
for folder_data in folder_data_list:
name = folder_data["group"]["group"].strip()
if name in existing_folders:
folders_to_delete.append((name, existing_folders[name]))
if name in folders_to_scan:
del folders_to_scan[name]


# Start fetching rules from kept folders in background (parallel to deletions)
existing_rules_future = shared_executor.submit(
get_all_existing_rules, client, profile_id, folders_to_scan
)
Comment on lines +2283 to +2285
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical Thread Safety Issue: The httpx.Client object is not thread-safe when used from multiple threads simultaneously. Here, the client is submitted to shared_executor to run get_all_existing_rules, which internally creates another ThreadPoolExecutor (5 workers) that also uses the same client object. This means the same httpx.Client instance is being used from multiple threads concurrently, which can lead to race conditions, corrupted connection state, or crashes.

According to httpx documentation, Client objects should not be shared across threads without synchronization. The recommended approach is to either:

  1. Create a new client within the background task, or
  2. Use httpx.AsyncClient with asyncio for concurrent operations, or
  3. Add proper locking around all client operations (not recommended due to performance impact)

Copilot uses AI. Check for mistakes.

if not no_delete:
deletion_occurred = False
if folders_to_delete:
# Parallel delete to speed up the "clean slate" phase
# Use shared_executor (3 workers)
Expand Down Expand Up @@ -2309,10 +2318,13 @@ def _fetch_if_valid(url: str):
)
countdown_timer(60, "Waiting for deletions to propagate")

# Optimization: Pass the updated existing_folders to avoid redundant API call
existing_rules = get_all_existing_rules(
client, profile_id, known_folders=existing_folders
)
# Retrieve result from background task
# If deletion occurred, we effectively used the wait time to fetch rules!
try:
existing_rules = existing_rules_future.result()
except Exception as e:
log.error(f"Failed to fetch existing rules in background: {sanitize_for_log(e)}")
existing_rules = set()

with concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers
Expand Down
2 changes: 2 additions & 0 deletions test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ def test_interactive_prompts_show_hints(monkeypatch, capsys):
mock_args.dry_run = False
mock_args.no_delete = False
mock_args.plan_json = None
mock_args.clear_cache = False
monkeypatch.setattr(m, "parse_args", lambda: mock_args)

# Mock internal functions to abort execution safely after prompts
Expand Down Expand Up @@ -434,6 +435,7 @@ def test_interactive_input_extracts_id(monkeypatch, capsys):
mock_args.dry_run = False
mock_args.no_delete = False
mock_args.plan_json = None
mock_args.clear_cache = False
monkeypatch.setattr(m, "parse_args", lambda: mock_args)

# Mock sync_profile to catch the call
Expand Down
92 changes: 92 additions & 0 deletions tests/test_parallel_fetch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@

Check warning

Code scanning / Pylint (reported by Codacy)

Missing module docstring Warning test

Missing module docstring

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing module docstring Warning test

Missing module docstring
import unittest
from unittest.mock import MagicMock, patch
import time
import concurrent.futures

Check warning

Code scanning / Prospector (reported by Codacy)

Unused import concurrent.futures (unused-import) Warning test

Unused import concurrent.futures (unused-import)

Check notice

Code scanning / Pylint (reported by Codacy)

Unused import concurrent.futures Note test

Unused import concurrent.futures

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused import concurrent.futures Note test

Unused import concurrent.futures
import sys
import os

# Add root to path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The sys.path.append is generally discouraged in tests as it can lead to unexpected module resolution issues. It's better to use relative imports or ensure the test runner correctly sets up the Python path.

import main

Check warning

Code scanning / Prospector (reported by Codacy)

Import "import main" should be placed at the top of the module (wrong-import-position) Warning test

Import "import main" should be placed at the top of the module (wrong-import-position)

Check warning

Code scanning / Pylint (reported by Codacy)

Import "import main" should be placed at the top of the module Warning test

Import "import main" should be placed at the top of the module

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Import "import main" should be placed at the top of the module Warning test

Import "import main" should be placed at the top of the module

Check warning

Code scanning / Prospector (reported by Codacy)

Cannot import 'main' due to syntax error 'invalid syntax (, line 1141)' (syntax-error) Warning test

Cannot import 'main' due to syntax error 'invalid syntax (, line 1141)' (syntax-error)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Cannot import 'main' due to syntax error 'invalid syntax (, line 1141)' Warning test

Cannot import 'main' due to syntax error 'invalid syntax (, line 1141)'

class TestParallelFetch(unittest.TestCase):

Check warning

Code scanning / Pylint (reported by Codacy)

Missing class docstring Warning test

Missing class docstring

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing class docstring Warning test

Missing class docstring
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent Test Framework: This test file uses unittest.TestCase while the similar test file tests/test_parallel_deletion.py uses pytest. For consistency and to leverage pytest's better fixtures and parametrization, consider converting this test to use pytest instead of unittest. This would also make it easier to use monkeypatch and other pytest features.

Copilot uses AI. Check for mistakes.
def setUp(self):
self.main = main
self.client = MagicMock()
self.profile_id = "test_profile"
self.folder_urls = ["https://example.com/folder.json"]

# Mock fetch_folder_data to return dummy data
self.folder_data = {
"group": {"group": "test_folder", "action": {"do": 0, "status": 1}},
"rules": [{"PK": "rule1"}]
}

@patch("main.get_all_existing_rules")
@patch("main.delete_folder")
@patch("main.verify_access_and_get_folders")
@patch("main.fetch_folder_data")
@patch("main.validate_folder_url")
@patch("main.countdown_timer")
def test_parallel_execution(self, mock_timer, mock_validate, mock_fetch, mock_verify, mock_delete, mock_get_rules):

Check warning

Code scanning / Prospector (reported by Codacy)

Too many arguments (7/5) (too-many-arguments) Warning test

Too many arguments (7/5) (too-many-arguments)

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'mock_timer' (unused-argument) Warning test

Unused argument 'mock_timer' (unused-argument)

Check warning

Code scanning / Pylint (reported by Codacy)

Too many arguments (7/5) Warning test

Too many arguments (7/5)

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (119/100) Warning test

Line too long (119/100)

Check warning

Code scanning / Pylint (reported by Codacy)

Too many local variables (16/15) Warning test

Too many local variables (16/15)

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'mock_timer' Note test

Unused argument 'mock_timer'

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Line too long (119/100) Warning test

Line too long (119/100)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Too many arguments (7/5) Warning test

Too many arguments (7/5)

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'mock_timer' Note test

Unused argument 'mock_timer'
"""
Verify that get_all_existing_rules runs in parallel with delete_folder.
"""
# Setup mocks
mock_validate.return_value = True
mock_fetch.return_value = self.folder_data

# existing folders: test_folder (to be deleted), keep_folder (to be kept)
mock_verify.return_value = {
"test_folder": "id_1",
"keep_folder": "id_2"
}

# Latency simulation
def slow_get_rules(*args, **kwargs):

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'args' (unused-argument) Warning test

Unused argument 'args' (unused-argument)

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'kwargs' (unused-argument) Warning test

Unused argument 'kwargs' (unused-argument)

Check warning

Code scanning / Pylint (reported by Codacy)

Missing function docstring Warning test

Missing function docstring

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'kwargs' Note test

Unused argument 'kwargs'

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'args' Note test

Unused argument 'args'

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'args' Note test

Unused argument 'args'

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'kwargs' Note test

Unused argument 'kwargs'
time.sleep(1.0)
return {"rule_from_keep"}

def slow_delete(*args, **kwargs):

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'kwargs' (unused-argument) Warning test

Unused argument 'kwargs' (unused-argument)

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'args' (unused-argument) Warning test

Unused argument 'args' (unused-argument)

Check warning

Code scanning / Pylint (reported by Codacy)

Missing function docstring Warning test

Missing function docstring

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'args' Note test

Unused argument 'args'

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'kwargs' Note test

Unused argument 'kwargs'

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'kwargs' Note test

Unused argument 'kwargs'

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'args' Note test

Unused argument 'args'
time.sleep(1.0)
return True

mock_get_rules.side_effect = slow_get_rules
mock_delete.side_effect = slow_delete

# Mock create_folder and push_rules to avoid errors
with patch("main.create_folder") as mock_create, \
patch("main.push_rules") as mock_push:

mock_create.return_value = "new_id"
mock_push.return_value = True

start = time.perf_counter()
self.main.sync_profile(self.profile_id, self.folder_urls, no_delete=False)
elapsed = time.perf_counter() - start

# Assertions
self.assertTrue(mock_delete.called, "delete_folder should be called")
self.assertTrue(mock_get_rules.called, "get_all_existing_rules should be called")

# Verify get_all_existing_rules was called with ONLY keep_folder
call_args = mock_get_rules.call_args
# args: client, profile_id, known_folders
known_folders = call_args[0][2] if len(call_args[0]) > 2 else call_args[1]['known_folders']

self.assertIn("keep_folder", known_folders)
self.assertNotIn("test_folder", known_folders, "Should not fetch rules from deleted folder")

Check warning

Code scanning / Pylint (reported by Codacy)

Line too long (104/100) Warning test

Line too long (104/100)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Line too long (104/100) Warning test

Line too long (104/100)

# Timing check
# If serial: 1s (delete) + 1s (fetch) + overhead = ~2s
# If parallel: max(1s, 1s) + overhead = ~1s
# Note: sync_profile also has overhead (fetch_folder_data etc), but that's mocked to be fast.
# We allow some buffer, but it should be significantly less than 2s
print(f"Elapsed time: {elapsed:.2f}s")

# In the CURRENT implementation (serial), this test should FAIL (take > 2s)
# Once implemented, it should PASS (take < 1.5s)

# Verify optimization effectiveness (should be close to max(1.0, 1.0) = 1.0s, allow 1.5s buffer)
self.assertLess(elapsed, 1.5, "Parallel execution took too long")
Loading