ComfyUI-Manager/comfyui_manager/common/pip_util.test-design.md
Dr.Lt.Data 2866193baf ● feat: Draft pip package policy management system (not yet integrated)
Add comprehensive pip dependency conflict resolution framework as draft implementation. This is self-contained and does not affect existing
ComfyUI Manager functionality.

Key components:
- pip_util.py with PipBatch class for policy-driven package management
- Lazy-loaded policy system supporting base + user overrides
- Multi-stage policy execution (uninstall → apply_first_match → apply_all_matches → restore)
- Conditional policies based on platform, installed packages, and ComfyUI version
- Comprehensive test suite covering edge cases, workflows, and platform scenarios
- Design and implementation documentation

Policy capabilities (draft):
- Package replacement (e.g., PIL → Pillow, opencv-python → opencv-contrib-python)
- Version pinning to prevent dependency conflicts
- Dependency protection during installations
- Platform-specific handling (Linux/Windows, GPU detection)
- Pre-removal and post-restoration workflows

Testing infrastructure:
- Pytest-based test suite with isolated environments
- Dependency analysis tools for conflict detection
- Coverage for policy priority, edge cases, and environment recovery

Status: Draft implementation complete, integration with manager workflows pending.
2025-10-04 08:55:59 +09:00

2917 lines
67 KiB
Markdown

# Test Design Document for pip_util.py (TDD)
## 1. Test Strategy Overview
### Testing Philosophy
- **Test-First Approach**: Write tests before implementation
- **Comprehensive Coverage**: Target ≥80% code coverage
- **Isolated Testing**: Each test should be independent and repeatable
- **Clear Assertions**: Each test validates a single behavior
- **Mock External Dependencies**: Isolate unit under test from system calls
- **Environment Isolation**: Use dedicated venv to prevent Python environment corruption
### Test Pyramid Structure
```
/\
/ \ E2E Tests (5%)
/ \ Policy Integration Tests (60%)
/ \ Unit Tests (35%)
/________\
```
**Focus**: Policy application behavior rather than JSON format validation
### Test Environment Setup
**⚠️ IMPORTANT: Always use isolated virtual environment for testing**
```bash
# Initial setup (first time only)
cd tests
./setup_test_env.sh
# Activate test environment before running tests
source test_venv/bin/activate
# Run pip_util tests
cd common/pip_util
pytest
# Deactivate when done
deactivate
```
**Why isolated environment?**
- ✅ Prevents test dependencies from corrupting main Python environment
- ✅ Allows safe installation/uninstallation during tests
- ✅ Ensures consistent test results across machines
- ✅ Easy to recreate clean environment
**Test Directory Structure**:
```
tests/ # Project-level test directory
├── setup_test_env.sh # Automated venv setup script
├── requirements.txt # Test-specific dependencies
├── pytest.ini # Global pytest configuration
├── README.md # Test suite overview
└── common/ # Tests for comfyui_manager/common/
└── pip_util/ # Tests for pip_util.py
├── conftest.py # pip_util-specific fixtures
├── pytest.ini # pip_util-specific pytest config
├── README.md # Detailed test execution guide
└── test_*.py # Actual test files
```
**Test Infrastructure Files**:
- `tests/setup_test_env.sh` - Automated venv setup script
- `tests/requirements.txt` - Test-specific dependencies
- `tests/pytest.ini` - Global pytest configuration
- `tests/common/pip_util/conftest.py` - pip_util test fixtures
- `tests/common/pip_util/pytest.ini` - pip_util coverage settings
- `tests/common/pip_util/README.md` - Detailed execution guide
- `tests/.gitignore` - Exclude venv and artifacts from version control
---
## 2. Unit Tests
### 2.1 Policy Loading Tests (`test_get_pip_policy.py`)
#### Test: `test_get_pip_policy_caching`
**Purpose**: Verify policy is loaded only once and cached
**Setup**:
- Policy file with basic content
**Execution**:
```python
policy1 = get_pip_policy()
policy2 = get_pip_policy()
```
**Assertions**:
- `policy1 is policy2` (same object reference)
- File read operations occur only once (verify with mock)
- Debug log shows "Returning cached pip policy" on second call
**Expected Result**: Policy is cached and reused
---
#### Test: `test_get_pip_policy_user_override_replaces_package`
**Purpose**: Verify user policy completely replaces base policy per package
**Setup**:
- Base policy: `{"numpy": {"apply_first_match": [{"type": "skip"}]}}`
- User policy: `{"numpy": {"apply_first_match": [{"type": "force_version", "version": "1.26.0"}]}}`
**Execution**:
```python
policy = get_pip_policy()
```
**Assertions**:
- `policy["numpy"]["apply_first_match"][0]["type"] == "force_version"`
- Base numpy policy is completely replaced (not merged at section level)
**Expected Result**: User policy completely overrides base policy for numpy
---
### 2.2 Package Spec Parsing Tests (`test_parse_package_spec.py`)
#### Test: `test_parse_package_spec_name_only`
**Purpose**: Parse package name without version
**Execution**:
```python
batch = PipBatch()
name, spec = batch._parse_package_spec("numpy")
```
**Assertions**:
- `name == "numpy"`
- `spec is None`
**Expected Result**: Package name extracted, no version spec
---
#### Test: `test_parse_package_spec_exact_version`
**Purpose**: Parse package with exact version
**Execution**:
```python
name, spec = batch._parse_package_spec("numpy==1.26.0")
```
**Assertions**:
- `name == "numpy"`
- `spec == "==1.26.0"`
**Expected Result**: Name and exact version spec extracted
---
#### Test: `test_parse_package_spec_min_version`
**Purpose**: Parse package with minimum version
**Execution**:
```python
name, spec = batch._parse_package_spec("pandas>=2.0.0")
```
**Assertions**:
- `name == "pandas"`
- `spec == ">=2.0.0"`
**Expected Result**: Name and minimum version spec extracted
---
#### Test: `test_parse_package_spec_max_version`
**Purpose**: Parse package with maximum version
**Execution**:
```python
name, spec = batch._parse_package_spec("scipy<1.10.0")
```
**Assertions**:
- `name == "scipy"`
- `spec == "<1.10.0"`
**Expected Result**: Name and maximum version spec extracted
---
#### Test: `test_parse_package_spec_compatible_version`
**Purpose**: Parse package with compatible version (~=)
**Execution**:
```python
name, spec = batch._parse_package_spec("requests~=2.28")
```
**Assertions**:
- `name == "requests"`
- `spec == "~=2.28"`
**Expected Result**: Name and compatible version spec extracted
---
#### Test: `test_parse_package_spec_hyphenated_name`
**Purpose**: Parse package with hyphens in name
**Execution**:
```python
name, spec = batch._parse_package_spec("scikit-learn>=1.0")
```
**Assertions**:
- `name == "scikit-learn"`
- `spec == ">=1.0"`
**Expected Result**: Hyphenated name correctly parsed
---
#### Test: `test_parse_package_spec_invalid_format`
**Purpose**: Verify ValueError on invalid format
**Execution**:
```python
batch._parse_package_spec("invalid package name!")
```
**Assertions**:
- `ValueError` is raised
- Error message contains "Invalid package spec"
**Expected Result**: ValueError raised for invalid format
---
### 2.3 Condition Evaluation Tests (`test_evaluate_condition.py`)
#### Test: `test_evaluate_condition_none`
**Purpose**: Verify None condition always returns True
**Execution**:
```python
result = batch._evaluate_condition(None, "numpy", {})
```
**Assertions**:
- `result is True`
**Expected Result**: None condition is always satisfied
---
#### Test: `test_evaluate_condition_installed_package_exists`
**Purpose**: Verify installed condition when package exists
**Setup**:
```python
condition = {"type": "installed", "package": "numpy"}
installed = {"numpy": "1.26.0"}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "numba", installed)
```
**Assertions**:
- `result is True`
**Expected Result**: Condition satisfied when package is installed
---
#### Test: `test_evaluate_condition_installed_package_not_exists`
**Purpose**: Verify installed condition when package doesn't exist
**Setup**:
```python
condition = {"type": "installed", "package": "numpy"}
installed = {}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "numba", installed)
```
**Assertions**:
- `result is False`
**Expected Result**: Condition not satisfied when package is missing
---
#### Test: `test_evaluate_condition_installed_version_match`
**Purpose**: Verify version spec matching
**Setup**:
```python
condition = {"type": "installed", "package": "numpy", "spec": ">=1.20.0"}
installed = {"numpy": "1.26.0"}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "numba", installed)
```
**Assertions**:
- `result is True`
**Expected Result**: Condition satisfied when version matches spec
---
#### Test: `test_evaluate_condition_installed_version_no_match`
**Purpose**: Verify version spec not matching
**Setup**:
```python
condition = {"type": "installed", "package": "numpy", "spec": ">=2.0.0"}
installed = {"numpy": "1.26.0"}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "numba", installed)
```
**Assertions**:
- `result is False`
**Expected Result**: Condition not satisfied when version doesn't match
---
#### Test: `test_evaluate_condition_installed_self_reference`
**Purpose**: Verify self-reference when package field omitted
**Setup**:
```python
condition = {"type": "installed", "spec": ">=1.0.0"}
installed = {"numpy": "1.26.0"}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "numpy", installed)
```
**Assertions**:
- `result is True`
**Expected Result**: Package name defaults to self when not specified
---
#### Test: `test_evaluate_condition_platform_os_match`
**Purpose**: Verify platform OS condition matching
**Setup**:
```python
condition = {"type": "platform", "os": platform.system().lower()}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "package", {})
```
**Assertions**:
- `result is True`
**Expected Result**: Condition satisfied when OS matches
---
#### Test: `test_evaluate_condition_platform_os_no_match`
**Purpose**: Verify platform OS condition not matching
**Setup**:
```python
current_os = platform.system().lower()
other_os = "fakeos" if current_os != "fakeos" else "anotherfakeos"
condition = {"type": "platform", "os": other_os}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "package", {})
```
**Assertions**:
- `result is False`
**Expected Result**: Condition not satisfied when OS doesn't match
---
#### Test: `test_evaluate_condition_platform_gpu_available`
**Purpose**: Verify GPU detection (mock torch.cuda)
**Setup**:
- Mock `torch.cuda.is_available()` to return True
- Condition: `{"type": "platform", "has_gpu": True}`
**Execution**:
```python
result = batch._evaluate_condition(condition, "package", {})
```
**Assertions**:
- `result is True`
**Expected Result**: Condition satisfied when GPU is available
---
#### Test: `test_evaluate_condition_platform_gpu_not_available`
**Purpose**: Verify GPU not available
**Setup**:
- Mock `torch.cuda.is_available()` to return False
- Condition: `{"type": "platform", "has_gpu": True}`
**Execution**:
```python
result = batch._evaluate_condition(condition, "package", {})
```
**Assertions**:
- `result is False`
**Expected Result**: Condition not satisfied when GPU is not available
---
#### Test: `test_evaluate_condition_platform_torch_not_installed`
**Purpose**: Verify behavior when torch is not installed
**Setup**:
- Mock torch import to raise ImportError
- Condition: `{"type": "platform", "has_gpu": True}`
**Execution**:
```python
result = batch._evaluate_condition(condition, "package", {})
```
**Assertions**:
- `result is False`
**Expected Result**: GPU assumed unavailable when torch not installed
---
#### Test: `test_evaluate_condition_unknown_type`
**Purpose**: Verify handling of unknown condition type
**Setup**:
```python
condition = {"type": "unknown_type"}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "package", {})
```
**Assertions**:
- `result is False`
- Warning log is generated
**Expected Result**: Unknown condition type returns False with warning
---
### 2.4 pip freeze Caching Tests (`test_pip_freeze_cache.py`)
#### Test: `test_refresh_installed_cache_success`
**Purpose**: Verify pip freeze parsing
**Setup**:
- Mock `manager_util.make_pip_cmd()` to return `["pip", "freeze"]`
- Mock `subprocess.run()` to return:
```
numpy==1.26.0
pandas==2.0.0
scipy==1.11.0
```
**Execution**:
```python
batch._refresh_installed_cache()
```
**Assertions**:
- `batch._installed_cache == {"numpy": "1.26.0", "pandas": "2.0.0", "scipy": "1.11.0"}`
- Debug log shows "Refreshed installed packages cache: 3 packages"
**Expected Result**: Cache populated with parsed packages
---
#### Test: `test_refresh_installed_cache_skip_editable`
**Purpose**: Verify editable packages are skipped
**Setup**:
- Mock subprocess to return:
```
numpy==1.26.0
-e git+https://github.com/user/repo.git@main#egg=mypackage
pandas==2.0.0
```
**Execution**:
```python
batch._refresh_installed_cache()
```
**Assertions**:
- `"mypackage" not in batch._installed_cache`
- `"numpy" in batch._installed_cache`
- `"pandas" in batch._installed_cache`
**Expected Result**: Editable packages ignored
---
#### Test: `test_refresh_installed_cache_skip_comments`
**Purpose**: Verify comments are skipped
**Setup**:
- Mock subprocess to return:
```
# This is a comment
numpy==1.26.0
## Another comment
pandas==2.0.0
```
**Execution**:
```python
batch._refresh_installed_cache()
```
**Assertions**:
- Cache contains only numpy and pandas
- No comment lines in cache
**Expected Result**: Comments ignored
---
#### Test: `test_refresh_installed_cache_pip_freeze_fails`
**Purpose**: Verify handling of pip freeze failure
**Setup**:
- Mock `subprocess.run()` to raise `CalledProcessError`
**Execution**:
```python
batch._refresh_installed_cache()
```
**Assertions**:
- `batch._installed_cache == {}`
- Warning log is generated
**Expected Result**: Empty cache with warning on failure
---
#### Test: `test_get_installed_packages_lazy_load`
**Purpose**: Verify lazy loading of cache
**Setup**:
- `batch._installed_cache = None`
**Execution**:
```python
packages = batch._get_installed_packages()
```
**Assertions**:
- `_refresh_installed_cache()` is called (verify with mock)
- `packages` contains parsed packages
**Expected Result**: Cache is loaded on first access
---
#### Test: `test_get_installed_packages_use_cache`
**Purpose**: Verify cache is reused
**Setup**:
- `batch._installed_cache = {"numpy": "1.26.0"}`
**Execution**:
```python
packages = batch._get_installed_packages()
```
**Assertions**:
- `_refresh_installed_cache()` is NOT called
- `packages == {"numpy": "1.26.0"}`
**Expected Result**: Existing cache is returned
---
#### Test: `test_invalidate_cache`
**Purpose**: Verify cache invalidation
**Setup**:
- `batch._installed_cache = {"numpy": "1.26.0"}`
**Execution**:
```python
batch._invalidate_cache()
```
**Assertions**:
- `batch._installed_cache is None`
**Expected Result**: Cache is cleared
---
## 3. Policy Application Tests (Integration)
### 3.1 apply_first_match Policy Tests (`test_apply_first_match.py`)
#### Test: `test_skip_policy_blocks_installation`
**Purpose**: Verify skip policy prevents installation
**Setup**:
- Policy: `{"torch": {"apply_first_match": [{"type": "skip", "reason": "Manual CUDA management"}]}}`
**Execution**:
```python
with PipBatch() as batch:
result = batch.install("torch")
```
**Assertions**:
- `result is False`
- pip install is NOT called
- Info log: "Skipping installation of torch: Manual CUDA management"
**Expected Result**: Installation blocked by skip policy
---
#### Test: `test_force_version_overrides_requested_version`
**Purpose**: Verify force_version changes requested version
**Setup**:
- Policy: `{"numba": {"apply_first_match": [{"type": "force_version", "version": "0.57.0"}]}}`
- Request: `"numba>=0.58"`
**Execution**:
```python
result = batch.install("numba>=0.58")
```
**Assertions**:
- pip install called with "numba==0.57.0" (NOT "numba>=0.58")
- Info log shows forced version
**Expected Result**: Requested version replaced with policy version
---
#### Test: `test_force_version_with_condition_numpy_compatibility`
**Purpose**: Verify conditional force_version for numba/numpy compatibility
**Setup**:
- Policy:
```json
{
"numba": {
"apply_first_match": [
{
"condition": {"type": "installed", "package": "numpy", "spec": "<2.0.0"},
"type": "force_version",
"version": "0.57.0",
"reason": "numba 0.58+ requires numpy >=2.0.0"
}
]
}
}
```
- Installed: `{"numpy": "1.26.0"}`
**Execution**:
```python
result = batch.install("numba")
```
**Assertions**:
- Condition satisfied (numpy 1.26.0 < 2.0.0)
- pip install called with "numba==0.57.0"
- Info log shows compatibility reason
**Expected Result**: Compatible numba version installed based on numpy version
---
#### Test: `test_force_version_condition_not_met_uses_default`
**Purpose**: Verify default installation when condition fails
**Setup**:
- Same policy as above
- Installed: `{"numpy": "2.1.0"}`
**Execution**:
```python
result = batch.install("numba")
```
**Assertions**:
- Condition NOT satisfied (numpy 2.1.0 >= 2.0.0)
- pip install called with "numba" (original request, no version forcing)
**Expected Result**: Default installation when condition not met
---
#### Test: `test_replace_PIL_with_Pillow`
**Purpose**: Verify package replacement policy
**Setup**:
- Policy: `{"PIL": {"apply_first_match": [{"type": "replace", "replacement": "Pillow"}]}}`
**Execution**:
```python
result = batch.install("PIL")
```
**Assertions**:
- pip install called with "Pillow" (NOT "PIL")
- Info log: "Replacing PIL with Pillow"
**Expected Result**: Deprecated package replaced with modern alternative
---
#### Test: `test_replace_opencv_to_contrib`
**Purpose**: Verify replacement with version spec
**Setup**:
- Policy: `{"opencv-python": {"apply_first_match": [{"type": "replace", "replacement": "opencv-contrib-python", "version": ">=4.8.0"}]}}`
**Execution**:
```python
result = batch.install("opencv-python")
```
**Assertions**:
- pip install called with "opencv-contrib-python>=4.8.0"
**Expected Result**: Package replaced with enhanced version
---
#### Test: `test_replace_onnxruntime_gpu_on_linux`
**Purpose**: Verify platform-conditional replacement
**Setup**:
- Policy:
```json
{
"onnxruntime": {
"apply_first_match": [
{
"condition": {"type": "platform", "os": "linux", "has_gpu": true},
"type": "replace",
"replacement": "onnxruntime-gpu"
}
]
}
}
```
- Platform: Linux with GPU
**Execution**:
```python
result = batch.install("onnxruntime")
```
**Assertions**:
- Condition satisfied (Linux + GPU)
- pip install called with "onnxruntime-gpu"
**Expected Result**: GPU version installed on compatible platform
---
#### Test: `test_first_match_only_one_policy_executed`
**Purpose**: Verify only first matching policy is applied
**Setup**:
- Policy with multiple matching conditions:
```json
{
"pkg": {
"apply_first_match": [
{"type": "force_version", "version": "1.0"},
{"type": "force_version", "version": "2.0"},
{"type": "skip"}
]
}
}
```
**Execution**:
```python
result = batch.install("pkg")
```
**Assertions**:
- Only first policy applied
- pip install called with "pkg==1.0" (NOT "pkg==2.0")
**Expected Result**: Exclusive execution - first match only
---
#### Test: `test_extra_index_url_from_force_version_policy`
**Purpose**: Verify custom repository URL from policy
**Setup**:
- Policy: `{"pkg": {"apply_first_match": [{"type": "force_version", "version": "1.0", "extra_index_url": "https://custom.repo/simple"}]}}`
**Execution**:
```python
result = batch.install("pkg")
```
**Assertions**:
- pip install called with "--extra-index-url https://custom.repo/simple"
**Expected Result**: Custom repository used from policy
---
### 3.2 apply_all_matches Policy Tests (`test_apply_all_matches.py`)
#### Test: `test_pin_dependencies_prevents_upgrades`
**Purpose**: Verify dependency pinning to current versions
**Setup**:
- Policy:
```json
{
"new-experimental-pkg": {
"apply_all_matches": [
{
"type": "pin_dependencies",
"pinned_packages": ["numpy", "pandas", "scipy"]
}
]
}
}
```
- Installed: `{"numpy": "1.26.0", "pandas": "2.0.0", "scipy": "1.11.0"}`
**Execution**:
```python
result = batch.install("new-experimental-pkg")
```
**Assertions**:
- pip install called with:
```
["new-experimental-pkg", "numpy==1.26.0", "pandas==2.0.0", "scipy==1.11.0"]
```
- Info log shows pinned packages
**Expected Result**: Dependencies pinned to prevent breaking changes
---
#### Test: `test_pin_dependencies_skip_uninstalled_packages`
**Purpose**: Verify pinning only installed packages
**Setup**:
- Policy pins ["numpy", "pandas", "scipy"]
- Installed: `{"numpy": "1.26.0"}` (only numpy installed)
**Execution**:
```python
result = batch.install("new-pkg")
```
**Assertions**:
- pip install called with ["new-pkg", "numpy==1.26.0"]
- pandas and scipy NOT pinned (not installed)
- Warning log for packages that couldn't be pinned
**Expected Result**: Only installed packages pinned
---
#### Test: `test_pin_dependencies_retry_without_pin_on_failure`
**Purpose**: Verify retry logic when pinning causes failure
**Setup**:
- Policy with `on_failure: "retry_without_pin"`
- Mock first install to fail, second to succeed
**Execution**:
```python
result = batch.install("new-pkg")
```
**Assertions**:
- First call: ["new-pkg", "numpy==1.26.0", "pandas==2.0.0"] → fails
- Second call: ["new-pkg"] → succeeds
- Warning log: "Installation failed with pinned dependencies, retrying without pins"
- `result is True`
**Expected Result**: Successful retry without pins
---
#### Test: `test_pin_dependencies_fail_on_failure`
**Purpose**: Verify hard failure when on_failure is "fail"
**Setup**:
- Policy:
```json
{
"pytorch-addon": {
"apply_all_matches": [
{
"condition": {"type": "installed", "package": "torch", "spec": ">=2.0.0"},
"type": "pin_dependencies",
"pinned_packages": ["torch", "torchvision", "torchaudio"],
"on_failure": "fail"
}
]
}
}
```
- Installed: `{"torch": "2.1.0", "torchvision": "0.16.0", "torchaudio": "2.1.0"}`
- Mock install to fail
**Execution**:
```python
result = batch.install("pytorch-addon")
```
**Assertions**:
- Exception raised
- No retry attempted
- Error log shows installation failure
**Expected Result**: Hard failure prevents PyTorch ecosystem breakage
---
#### Test: `test_install_with_adds_dependencies`
**Purpose**: Verify additional dependencies are installed together
**Setup**:
- Policy:
```json
{
"some-ml-package": {
"apply_all_matches": [
{
"condition": {"type": "installed", "package": "transformers", "spec": ">=4.30.0"},
"type": "install_with",
"additional_packages": ["accelerate>=0.20.0", "sentencepiece>=0.1.99"]
}
]
}
}
```
- Installed: `{"transformers": "4.35.0"}`
**Execution**:
```python
result = batch.install("some-ml-package")
```
**Assertions**:
- pip install called with:
```
["some-ml-package", "accelerate>=0.20.0", "sentencepiece>=0.1.99"]
```
- Info log shows additional packages
**Expected Result**: Required dependencies installed together
---
#### Test: `test_warn_policy_logs_and_continues`
**Purpose**: Verify warning policy logs message and continues
**Setup**:
- Policy:
```json
{
"tensorflow": {
"apply_all_matches": [
{
"condition": {"type": "installed", "package": "torch"},
"type": "warn",
"message": "Installing TensorFlow alongside PyTorch may cause CUDA conflicts"
}
]
}
}
```
- Installed: `{"torch": "2.1.0"}`
**Execution**:
```python
result = batch.install("tensorflow")
```
**Assertions**:
- Warning log shows CUDA conflict message
- Installation proceeds
- `result is True`
**Expected Result**: Warning logged, installation continues
---
#### Test: `test_multiple_apply_all_matches_cumulative`
**Purpose**: Verify all matching policies are applied (not just first)
**Setup**:
- Policy:
```json
{
"pkg": {
"apply_all_matches": [
{"type": "install_with", "additional_packages": ["dep1"]},
{"type": "install_with", "additional_packages": ["dep2"]},
{"type": "warn", "message": "Test warning"}
]
}
}
```
**Execution**:
```python
result = batch.install("pkg")
```
**Assertions**:
- ALL policies executed (not just first)
- pip install called with ["pkg", "dep1", "dep2"]
- Warning log present
**Expected Result**: Cumulative application of all matches
---
#### Test: `test_pin_and_install_with_combined`
**Purpose**: Verify pin_dependencies and install_with work together
**Setup**:
- Policy with both pin_dependencies and install_with
- Installed: `{"numpy": "1.26.0"}`
**Execution**:
```python
result = batch.install("pkg")
```
**Assertions**:
- pip install called with:
```
["pkg", "numpy==1.26.0", "extra-dep"]
```
**Expected Result**: Both policies applied together
---
### 3.3 uninstall Policy Tests (`test_uninstall_policy.py`)
#### Test: `test_uninstall_conflicting_package`
**Purpose**: Verify removal of conflicting package
**Setup**:
- Policy:
```json
{
"some-package": {
"uninstall": [
{
"condition": {"type": "installed", "package": "conflicting-package", "spec": ">=2.0.0"},
"target": "conflicting-package",
"reason": "conflicting-package >=2.0.0 conflicts with some-package"
}
]
}
}
```
- Installed: `{"conflicting-package": "2.1.0"}`
**Execution**:
```python
with PipBatch() as batch:
removed = batch.ensure_not_installed()
```
**Assertions**:
- `"conflicting-package" in removed`
- pip uninstall called with "-y conflicting-package"
- Info log shows conflict reason
- Package removed from cache
**Expected Result**: Conflicting package removed before installation
---
#### Test: `test_uninstall_unconditional_security_ban`
**Purpose**: Verify unconditional removal of banned package
**Setup**:
- Policy:
```json
{
"banned-malicious-package": {
"uninstall": [
{
"target": "banned-malicious-package",
"reason": "Security vulnerability CVE-2024-XXXXX"
}
]
}
}
```
- Installed: `{"banned-malicious-package": "1.0.0"}`
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- Package removed (no condition check)
- Info log shows security reason
**Expected Result**: Banned package always removed
---
#### Test: `test_uninstall_multiple_packages_first_match_per_package`
**Purpose**: Verify first-match-only rule per package
**Setup**:
- Multiple uninstall policies for same package
- All conditions satisfied
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- Only first matching policy executed per package
- Package removed only once
**Expected Result**: First match rule enforced
---
#### Test: `test_uninstall_continues_on_individual_failure`
**Purpose**: Verify batch continues on individual removal failure
**Setup**:
- Multiple packages to remove
- One removal fails
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- Other packages still processed
- Warning log for failed removal
- Partial success list returned
**Expected Result**: Batch resilience to individual failures
---
### 3.4 restore Policy Tests (`test_restore_policy.py`)
#### Test: `test_restore_missing_critical_package`
**Purpose**: Verify restoration of missing critical package
**Setup**:
- Policy:
```json
{
"critical-package": {
"restore": [
{
"target": "critical-package",
"version": "1.2.3",
"reason": "critical-package must be version 1.2.3"
}
]
}
}
```
- Installed: `{}` (package not installed)
**Execution**:
```python
with PipBatch() as batch:
restored = batch.ensure_installed()
```
**Assertions**:
- `"critical-package" in restored`
- pip install called with "critical-package==1.2.3"
- Info log shows restoration reason
- Cache updated with new version
**Expected Result**: Missing critical package restored
---
#### Test: `test_restore_wrong_version_of_critical_package`
**Purpose**: Verify restoration when version is wrong
**Setup**:
- Policy: restore to version 1.2.3
- Installed: `{"critical-package": "1.2.2"}`
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- Package restored to 1.2.3
- Info log shows version mismatch
**Expected Result**: Incorrect version corrected
---
#### Test: `test_restore_conditional_version_check`
**Purpose**: Verify conditional restoration
**Setup**:
- Policy:
```json
{
"critical-package": {
"restore": [
{
"condition": {"type": "installed", "package": "critical-package", "spec": "!=1.2.3"},
"target": "critical-package",
"version": "1.2.3"
}
]
}
}
```
- Installed: `{"critical-package": "1.2.3"}`
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- `restored == []` (condition not satisfied, version already correct)
- No pip install called
**Expected Result**: No action when version already correct
---
#### Test: `test_restore_with_extra_index_url`
**Purpose**: Verify custom repository for restoration
**Setup**:
- Policy with extra_index_url: "https://custom-repo.example.com/simple"
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- pip install called with "--extra-index-url https://custom-repo.example.com/simple"
**Expected Result**: Custom repository used for restoration
---
#### Test: `test_restore_different_package_target`
**Purpose**: Verify restore can target different package
**Setup**:
- Policy for package A restores package B
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- Package B restored (not A)
**Expected Result**: Cross-package restoration supported
---
### 3.5 ensure_not_installed() Tests (`test_ensure_not_installed.py`)
#### Test: `test_ensure_not_installed_remove_package`
**Purpose**: Remove package matching uninstall policy
**Setup**:
- Policy: `{"pkg": {"uninstall": [{"target": "conflicting-pkg"}]}}`
- Installed: `{"conflicting-pkg": "1.0.0"}`
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- `"conflicting-pkg" in removed`
- pip uninstall executed with "-y conflicting-pkg"
- Info log shows removal reason
**Expected Result**: Conflicting package removed
---
#### Test: `test_ensure_not_installed_package_not_installed`
**Purpose**: Skip removal if package not installed
**Setup**:
- Policy: `{"pkg": {"uninstall": [{"target": "missing-pkg"}]}}`
- Installed: `{}`
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- `removed == []`
- pip uninstall NOT called
**Expected Result**: No action when package not installed
---
#### Test: `test_ensure_not_installed_condition_satisfied`
**Purpose**: Remove only when condition satisfied
**Setup**:
- Policy with condition requiring numpy>=2.0
- Installed numpy is 1.26.0
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- Target package NOT removed (condition failed)
- `removed == []`
**Expected Result**: Condition prevents removal
---
#### Test: `test_ensure_not_installed_first_match_only`
**Purpose**: Execute only first matching policy
**Setup**:
- Multiple uninstall policies for same package
- All conditions satisfied
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- Only first policy executed
- Package removed only once
**Expected Result**: First match only
---
#### Test: `test_ensure_not_installed_failure_continues`
**Purpose**: Continue on individual removal failure
**Setup**:
- Multiple packages to remove
- One removal fails
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- Other packages still processed
- Warning log for failed removal
- Partial success list returned
**Expected Result**: Failure doesn't stop other removals
---
### 3.6 ensure_installed() Tests (`test_ensure_installed.py`)
#### Test: `test_ensure_installed_restore_missing_package`
**Purpose**: Restore missing package
**Setup**:
- Policy: `{"critical-pkg": {"restore": [{"target": "critical-pkg", "version": "1.0.0"}]}}`
- Installed: `{}`
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- `"critical-pkg" in restored`
- pip install executed with "critical-pkg==1.0.0"
- Info log shows restoration reason
**Expected Result**: Missing package restored
---
#### Test: `test_ensure_installed_restore_wrong_version`
**Purpose**: Restore package with wrong version
**Setup**:
- Policy: `{"pkg": {"restore": [{"target": "pkg", "version": "2.0.0"}]}}`
- Installed: `{"pkg": "1.0.0"}`
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- `"pkg" in restored`
- pip install executed with "pkg==2.0.0"
**Expected Result**: Wrong version replaced
---
#### Test: `test_ensure_installed_skip_correct_version`
**Purpose**: Skip restoration when version is correct
**Setup**:
- Policy: `{"pkg": {"restore": [{"target": "pkg", "version": "1.0.0"}]}}`
- Installed: `{"pkg": "1.0.0"}`
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- `restored == []`
- pip install NOT called
**Expected Result**: No action when version correct
---
#### Test: `test_ensure_installed_with_extra_index_url`
**Purpose**: Use custom repository for restoration
**Setup**:
- Policy with extra_index_url
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- pip install called with `--extra-index-url`
**Expected Result**: Custom repository used
---
#### Test: `test_ensure_installed_condition_check`
**Purpose**: Restore only when condition satisfied
**Setup**:
- Policy with condition
- Condition not met
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- Target package NOT restored
- `restored == []`
**Expected Result**: Condition prevents restoration
---
#### Test: `test_ensure_installed_failure_continues`
**Purpose**: Continue on individual restoration failure
**Setup**:
- Multiple packages to restore
- One restoration fails
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- Other packages still processed
- Warning log for failed restoration
- Partial success list returned
**Expected Result**: Failure doesn't stop other restorations
---
## 4. End-to-End Tests
### 4.1 Complete Workflow Test (`test_e2e_workflow.py`)
#### Test: `test_complete_batch_workflow`
**Purpose**: Test full batch operation sequence
**Setup**:
- Policy with uninstall, install, and restore policies
- Initial installed packages state
**Execution**:
```python
with PipBatch() as batch:
removed = batch.ensure_not_installed()
batch.install("numpy>=1.20")
batch.install("pandas>=2.0")
restored = batch.ensure_installed()
```
**Assertions**:
- All operations executed in correct order
- Cache invalidated at appropriate times
- Final package state matches expectations
**Expected Result**: Complete workflow succeeds
---
#### Test: `test_context_manager_cleanup`
**Purpose**: Verify context manager cleans up cache
**Execution**:
```python
with PipBatch() as batch:
batch.install("numpy")
# Cache exists here
# Cache should be None here
```
**Assertions**:
- Cache is cleared on exit
- No memory leaks
**Expected Result**: Automatic cleanup works
---
## 5. Real Environment Simulation Tests
This section simulates real pip environments to verify that policies work correctly in realistic scenarios.
### 5.1 Initial Environment Setup Tests (`test_environment_setup.py`)
#### Test: `test_preset_packages_installed`
**Purpose**: Simulate environment with pre-installed packages at test start
**Setup**:
```python
# Simulate pre-installed environment with fixture
installed_packages = {
"numpy": "1.26.0",
"pandas": "2.0.0",
"scipy": "1.11.0",
"torch": "2.1.0",
"torchvision": "0.16.0"
}
mock_pip_freeze_custom(installed_packages)
```
**Execution**:
```python
with PipBatch() as batch:
packages = batch._get_installed_packages()
```
**Assertions**:
- `packages == installed_packages`
- All preset packages are recognized
**Expected Result**: Initial environment is accurately simulated
---
### 5.2 Complex Dependency Scenario Tests (`test_complex_dependencies.py`)
#### Test: `test_dependency_version_protection_with_pin`
**Purpose**: Verify existing dependency versions are protected by pin during package installation
**Setup**:
- Policy:
```json
{
"new-experimental-pkg": {
"apply_all_matches": [
{
"type": "pin_dependencies",
"pinned_packages": ["numpy", "pandas", "scipy"],
"on_failure": "retry_without_pin"
}
]
}
}
```
- Installed: `{"numpy": "1.26.0", "pandas": "2.0.0", "scipy": "1.11.0"}`
- Mock: new-experimental-pkg attempts to upgrade numpy to 2.0.0
**Execution**:
```python
with PipBatch() as batch:
result = batch.install("new-experimental-pkg")
final_packages = batch._get_installed_packages()
```
**Assertions**:
- pip install command includes ["new-experimental-pkg", "numpy==1.26.0", "pandas==2.0.0", "scipy==1.11.0"]
- `final_packages["numpy"] == "1.26.0"` (version maintained)
- `final_packages["pandas"] == "2.0.0"` (version maintained)
**Expected Result**: Existing dependency versions are protected by pin
---
#### Test: `test_dependency_chain_with_numba_numpy`
**Purpose**: Verify numba-numpy dependency chain is handled correctly
**Setup**:
- Policy:
```json
{
"numba": {
"apply_first_match": [
{
"condition": {"type": "installed", "package": "numpy", "spec": "<2.0.0"},
"type": "force_version",
"version": "0.57.0"
}
],
"apply_all_matches": [
{
"type": "pin_dependencies",
"pinned_packages": ["numpy"]
}
]
}
}
```
- Installed: `{"numpy": "1.26.0"}`
**Execution**:
```python
result = batch.install("numba")
```
**Assertions**:
- Condition evaluation: numpy 1.26.0 < 2.0.0 True
- force_version applied: changed to numba==0.57.0
- pin_dependencies applied: numpy==1.26.0 added
- pip install command: ["numba==0.57.0", "numpy==1.26.0"]
- numpy version still 1.26.0 after installation
**Expected Result**: Dependency chain is handled correctly
---
### 5.3 Environment Corruption and Recovery Tests (`test_environment_recovery.py`)
#### Test: `test_package_deletion_and_restore`
**Purpose**: Verify critical package deleted by installation is restored by restore policy
**Setup**:
- Policy:
```json
{
"critical-package": {
"restore": [
{
"target": "critical-package",
"version": "1.2.3"
}
]
}
}
```
- Initial installed: `{"critical-package": "1.2.3", "numpy": "1.26.0"}`
- Mock: "breaking-package" installation deletes critical-package
**Execution**:
```python
with PipBatch() as batch:
# Install breaking-package → critical-package deleted
batch.install("breaking-package")
installed_after_install = batch._get_installed_packages()
# Restore with restore policy
restored = batch.ensure_installed()
final_packages = batch._get_installed_packages()
```
**Assertions**:
- `"critical-package" not in installed_after_install` (deletion confirmed)
- `"critical-package" in restored` (included in restore list)
- `final_packages["critical-package"] == "1.2.3"` (restored with correct version)
**Expected Result**: Deleted package is restored by restore policy
---
#### Test: `test_version_change_and_restore`
**Purpose**: Verify package version changed by installation is restored to original version
**Setup**:
- Policy:
```json
{
"critical-package": {
"restore": [
{
"condition": {"type": "installed", "spec": "!=1.2.3"},
"target": "critical-package",
"version": "1.2.3"
}
]
}
}
```
- Initial: `{"critical-package": "1.2.3"}`
- Mock: "version-changer-package" installation changes critical-package to 2.0.0
**Execution**:
```python
with PipBatch() as batch:
batch.install("version-changer-package")
installed_after = batch._get_installed_packages()
restored = batch.ensure_installed()
final = batch._get_installed_packages()
```
**Assertions**:
- `installed_after["critical-package"] == "2.0.0"` (changed)
- Condition evaluation: "2.0.0" != "1.2.3" True
- `"critical-package" in restored`
- `final["critical-package"] == "1.2.3"` (restored)
**Expected Result**: Changed version is restored to original version
---
## 6. Policy Execution Order and Interaction Tests
### 6.1 Full Workflow Integration Tests (`test_full_workflow_integration.py`)
#### Test: `test_uninstall_install_restore_workflow`
**Purpose**: Verify complete uninstall install restore workflow
**Setup**:
- Policy:
```json
{
"target-package": {
"uninstall": [
{
"condition": {"type": "installed", "package": "conflicting-pkg"},
"target": "conflicting-pkg"
}
],
"apply_all_matches": [
{
"type": "pin_dependencies",
"pinned_packages": ["numpy", "pandas"]
}
]
},
"critical-package": {
"restore": [
{
"target": "critical-package",
"version": "1.2.3"
}
]
}
}
```
- Initial: `{"conflicting-pkg": "1.0.0", "numpy": "1.26.0", "pandas": "2.0.0", "critical-package": "1.2.3"}`
- Mock: target-package installation deletes critical-package
**Execution**:
```python
with PipBatch() as batch:
# Step 1: uninstall
removed = batch.ensure_not_installed()
# Step 2: install
result = batch.install("target-package")
# Step 3: restore
restored = batch.ensure_installed()
```
**Assertions**:
- Step 1: `"conflicting-pkg" in removed`
- Step 2: pip install ["target-package", "numpy==1.26.0", "pandas==2.0.0"]
- Step 3: `"critical-package" in restored`
- Final state: conflicting-pkg removed, critical-package restored
**Expected Result**: Complete workflow executes in correct order
---
#### Test: `test_cache_invalidation_across_workflow`
**Purpose**: Verify cache is correctly refreshed at each workflow step
**Setup**:
- Policy with uninstall, install, restore
**Execution**:
```python
with PipBatch() as batch:
cache1 = batch._get_installed_packages()
removed = batch.ensure_not_installed()
cache2 = batch._get_installed_packages() # Should reload
batch.install("new-package")
cache3 = batch._get_installed_packages() # Should reload
restored = batch.ensure_installed()
cache4 = batch._get_installed_packages() # Should reload
```
**Assertions**:
- cache1: Initial state
- cache2: removed packages are gone
- cache3: new-package is added
- cache4: restored packages are added
- Cache is accurately refreshed at each step
**Expected Result**: Cache is correctly updated after each operation
---
### 6.2 Policy Conflict and Priority Tests (`test_policy_conflicts.py`)
#### Test: `test_user_policy_overrides_base_policy`
**Purpose**: Verify user policy completely overwrites base policy
**Setup**:
- Base policy:
```json
{
"numpy": {
"apply_first_match": [{"type": "skip"}]
}
}
```
- User policy:
```json
{
"numpy": {
"apply_first_match": [{"type": "force_version", "version": "1.26.0"}]
}
}
```
**Execution**:
```python
policy = get_pip_policy()
```
**Assertions**:
- `policy["numpy"]["apply_first_match"][0]["type"] == "force_version"`
- Base policy's skip is completely gone (not section-level merge)
**Expected Result**: User policy completely replaces base policy per package
---
#### Test: `test_first_match_stops_at_first_satisfied`
**Purpose**: Verify apply_first_match stops at first satisfied condition
**Setup**:
- Policy:
```json
{
"pkg": {
"apply_first_match": [
{"condition": {"type": "installed", "package": "numpy"}, "type": "force_version", "version": "1.0"},
{"type": "force_version", "version": "2.0"},
{"type": "skip"}
]
}
}
```
- Installed: `{"numpy": "1.26.0"}`
**Execution**:
```python
result = batch.install("pkg")
```
**Assertions**:
- First condition satisfied (numpy is installed)
- pip install called with "pkg==1.0" (NOT "pkg==2.0")
- Second and third policies not executed
**Expected Result**: Only first satisfied condition is executed (exclusive)
---
## 7. Failure and Recovery Scenario Tests
### 7.1 Pin Failure and Retry Tests (`test_pin_failure_retry.py`)
#### Test: `test_pin_failure_retry_without_pin_succeeds`
**Purpose**: Verify retry without pin succeeds when installation with pin fails
**Setup**:
- Policy:
```json
{
"new-pkg": {
"apply_all_matches": [
{
"type": "pin_dependencies",
"pinned_packages": ["numpy", "pandas"],
"on_failure": "retry_without_pin"
}
]
}
}
```
- Installed: `{"numpy": "1.26.0", "pandas": "2.0.0"}`
- Mock subprocess:
- First install ["new-pkg", "numpy==1.26.0", "pandas==2.0.0"] fails
- Second install ["new-pkg"] succeeds
**Execution**:
```python
result = batch.install("new-pkg")
```
**Assertions**:
- First subprocess call: ["pip", "install", "new-pkg", "numpy==1.26.0", "pandas==2.0.0"]
- First call fails
- Warning log: "Installation failed with pinned dependencies, retrying without pins"
- Second subprocess call: ["pip", "install", "new-pkg"]
- Second call succeeds
- `result is True`
**Expected Result**: Retry without pin succeeds after pin failure
---
#### Test: `test_pin_failure_with_fail_raises_exception`
**Purpose**: Verify exception is raised when on_failure is "fail"
**Setup**:
- Policy:
```json
{
"pytorch-addon": {
"apply_all_matches": [
{
"condition": {"type": "installed", "package": "torch", "spec": ">=2.0.0"},
"type": "pin_dependencies",
"pinned_packages": ["torch", "torchvision", "torchaudio"],
"on_failure": "fail"
}
]
}
}
```
- Installed: `{"torch": "2.1.0", "torchvision": "0.16.0", "torchaudio": "2.1.0"}`
- Mock: install fails
**Execution**:
```python
with pytest.raises(Exception):
batch.install("pytorch-addon")
```
**Assertions**:
- pip install attempted: ["pytorch-addon", "torch==2.1.0", "torchvision==0.16.0", "torchaudio==2.1.0"]
- Installation fails
- Exception raised (no retry)
- Error log recorded
**Expected Result**: Exception raised when on_failure="fail", process stops
---
### 7.2 Partial Failure Handling Tests (`test_partial_failures.py`)
#### Test: `test_ensure_not_installed_continues_on_individual_failure`
**Purpose**: Verify other packages are processed when individual package removal fails
**Setup**:
- Policy:
```json
{
"pkg-a": {"uninstall": [{"target": "old-pkg-1"}]},
"pkg-b": {"uninstall": [{"target": "old-pkg-2"}]},
"pkg-c": {"uninstall": [{"target": "old-pkg-3"}]}
}
```
- Installed: `{"old-pkg-1": "1.0", "old-pkg-2": "1.0", "old-pkg-3": "1.0"}`
- Mock: old-pkg-2 removal fails
**Execution**:
```python
removed = batch.ensure_not_installed()
```
**Assertions**:
- old-pkg-1 removal attempted → success
- old-pkg-2 removal attempted → failure → Warning log
- old-pkg-3 removal attempted → success
- `removed == ["old-pkg-1", "old-pkg-3"]`
**Expected Result**: Individual failure doesn't stop entire process
---
#### Test: `test_ensure_installed_continues_on_individual_failure`
**Purpose**: Verify other packages are processed when individual package restoration fails
**Setup**:
- Policy with 3 restore policies
- Mock: Second restore fails
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- First restore succeeds
- Second restore fails → Warning log
- Third restore succeeds
- `restored == ["pkg-1", "pkg-3"]`
**Expected Result**: Individual failure doesn't prevent other restores
---
## 8. Edge Cases and Boundary Condition Tests
### 8.1 Empty Policy Handling Tests (`test_empty_policies.py`)
#### Test: `test_empty_base_policy_uses_default_installation`
**Purpose**: Verify default installation behavior when base policy is empty
**Setup**:
- Base policy: `{}`
- User policy: `{}`
**Execution**:
```python
policy = get_pip_policy()
result = batch.install("numpy")
```
**Assertions**:
- `policy == {}`
- pip install called with ["numpy"] (no policy applied)
- `result is True`
**Expected Result**: Falls back to default installation when policy is empty
---
#### Test: `test_package_without_policy_default_installation`
**Purpose**: Verify package without policy is installed with default behavior
**Setup**:
- Policy: `{"numpy": {...}}` (no policy for pandas)
**Execution**:
```python
result = batch.install("pandas")
```
**Assertions**:
- pip install called with ["pandas"]
- No policy evaluation
- `result is True`
**Expected Result**: Package without policy is installed as-is
---
### 8.2 Malformed Policy Handling Tests (`test_malformed_policies.py`)
#### Test: `test_json_parse_error_fallback_to_empty`
**Purpose**: Verify empty dictionary is returned on JSON parse error
**Setup**:
- Base policy file: Malformed JSON (syntax error)
**Execution**:
```python
policy = get_pip_policy()
```
**Assertions**:
- Error log: "Failed to parse pip-policy.json"
- `policy == {}`
**Expected Result**: Empty dictionary returned on parse error
---
#### Test: `test_unknown_condition_type_returns_false`
**Purpose**: Verify False is returned for unknown condition type
**Setup**:
```python
condition = {"type": "unknown_type", "some_field": "value"}
```
**Execution**:
```python
result = batch._evaluate_condition(condition, "pkg", {})
```
**Assertions**:
- `result is False`
- Warning log: "Unknown condition type: unknown_type"
**Expected Result**: Unknown type treated as unsatisfied condition
---
### 8.3 Self-Reference Scenario Tests (`test_self_reference.py`)
#### Test: `test_restore_self_version_check`
**Purpose**: Verify restore policy checking its own package version
**Setup**:
- Policy:
```json
{
"critical-package": {
"restore": [
{
"condition": {"type": "installed", "spec": "!=1.2.3"},
"target": "critical-package",
"version": "1.2.3"
}
]
}
}
```
- Installed: `{"critical-package": "1.2.2"}`
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- Condition evaluation: package field omitted → check self ("critical-package")
- "1.2.2" != "1.2.3" → True
- `"critical-package" in restored`
- Final version: "1.2.3"
**Expected Result**: Reinstall when own version is incorrect
---
### 8.4 Circular Dependency Prevention Tests (`test_circular_dependencies.py`)
#### Test: `test_no_infinite_loop_in_restore`
**Purpose**: Verify circular dependency doesn't cause infinite loop in restore
**Setup**:
- Policy:
```json
{
"pkg-a": {
"restore": [
{
"condition": {"type": "installed", "package": "pkg-b", "spec": ">=1.0"},
"target": "pkg-a",
"version": "1.0"
}
]
},
"pkg-b": {
"restore": [
{
"condition": {"type": "installed", "package": "pkg-a", "spec": ">=1.0"},
"target": "pkg-b",
"version": "1.0"
}
]
}
}
```
- Installed: `{}`
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- First iteration: pkg-a, pkg-b conditions both unsatisfied (not installed)
- No recursive calls
- `restored == []`
**Expected Result**: Circular dependency doesn't cause infinite loop
**Notes**:
- Current design runs restore once, so no circular issue
- If recursive restore is needed, visited set or similar mechanism required
---
## 9. Platform and Environment Condition Tests
### 9.1 OS-Specific Behavior Tests (`test_platform_os.py`)
#### Test: `test_linux_gpu_uses_gpu_package`
**Purpose**: Verify GPU-specific package is installed on Linux + GPU environment
**Setup**:
- Policy:
```json
{
"onnxruntime": {
"apply_first_match": [
{
"condition": {"type": "platform", "os": "linux", "has_gpu": true},
"type": "replace",
"replacement": "onnxruntime-gpu"
}
]
}
}
```
- Mock: `platform.system() → "Linux"`, `torch.cuda.is_available() → True`
**Execution**:
```python
result = batch.install("onnxruntime")
```
**Assertions**:
- Condition evaluation: os="linux" ✓, has_gpu=True ✓
- Replace applied: onnxruntime → onnxruntime-gpu
- pip install ["onnxruntime-gpu"]
**Expected Result**: Replaced with GPU version
---
#### Test: `test_windows_no_gpu_uses_cpu_package`
**Purpose**: Verify CPU package is installed on Windows + No GPU environment
**Setup**:
- Same policy as above
- Mock: `platform.system() → "Windows"`, `torch.cuda.is_available() → False`
**Execution**:
```python
result = batch.install("onnxruntime")
```
**Assertions**:
- Condition evaluation: os="windows" ≠ "linux" → False
- Replace not applied
- pip install ["onnxruntime"] (original package)
**Expected Result**: Original package installed when condition not satisfied
---
### 9.2 GPU Detection Tests (`test_platform_gpu.py`)
#### Test: `test_torch_cuda_available_true`
**Purpose**: Verify GPU is recognized when torch.cuda.is_available() = True
**Setup**:
- Mock torch.cuda.is_available() → True
- Condition: `{"type": "platform", "has_gpu": true}`
**Execution**:
```python
result = batch._evaluate_condition(condition, "pkg", {})
```
**Assertions**:
- `result is True`
**Expected Result**: GPU recognized as available
---
#### Test: `test_torch_cuda_available_false`
**Purpose**: Verify GPU is not recognized when torch.cuda.is_available() = False
**Setup**:
- Mock torch.cuda.is_available() → False
**Execution**:
```python
result = batch._evaluate_condition(condition, "pkg", {})
```
**Assertions**:
- `result is False`
**Expected Result**: GPU recognized as unavailable
---
#### Test: `test_torch_not_installed_assumes_no_gpu`
**Purpose**: Verify GPU is assumed unavailable when torch is not installed
**Setup**:
- Mock torch import → ImportError
**Execution**:
```python
result = batch._evaluate_condition(condition, "pkg", {})
```
**Assertions**:
- ImportError handled
- `result is False`
**Expected Result**: Assumed no GPU when torch is not installed
---
### 9.3 ComfyUI Version Condition Tests (`test_platform_comfyui_version.py`)
#### Test: `test_comfyui_version_condition_not_implemented_warning`
**Purpose**: Verify warning for currently unimplemented comfyui_version condition
**Setup**:
- Condition: `{"type": "platform", "comfyui_version": ">=1.0.0"}`
**Execution**:
```python
result = batch._evaluate_condition(condition, "pkg", {})
```
**Assertions**:
- Warning log: "comfyui_version condition is not yet implemented"
- `result is False`
**Expected Result**: Warning for unimplemented feature and False returned
**Notes**: Change this test to actual implementation test when feature is implemented
---
## 10. extra_index_url Handling Tests
### 10.1 Policy URL Tests (`test_extra_index_url_policy.py`)
#### Test: `test_policy_extra_index_url_in_force_version`
**Purpose**: Verify extra_index_url from force_version policy is used
**Setup**:
- Policy:
```json
{
"pkg": {
"apply_first_match": [
{
"type": "force_version",
"version": "1.0.0",
"extra_index_url": "https://custom-repo.example.com/simple"
}
]
}
}
```
**Execution**:
```python
result = batch.install("pkg")
```
**Assertions**:
- pip install ["pkg==1.0.0", "--extra-index-url", "https://custom-repo.example.com/simple"]
**Expected Result**: Policy URL is included in command
---
#### Test: `test_parameter_url_overrides_policy_url`
**Purpose**: Verify parameter URL takes precedence over policy URL
**Setup**:
- Policy: extra_index_url = "https://policy-repo.com/simple"
- Parameter: extra_index_url = "https://param-repo.com/simple"
**Execution**:
```python
result = batch.install("pkg", extra_index_url="https://param-repo.com/simple")
```
**Assertions**:
- pip install uses "https://param-repo.com/simple" (NOT policy URL)
**Expected Result**: Parameter URL takes precedence
---
### 10.2 Restore URL Tests (`test_extra_index_url_restore.py`)
#### Test: `test_restore_with_extra_index_url`
**Purpose**: Verify extra_index_url from restore policy is used
**Setup**:
- Policy:
```json
{
"critical-pkg": {
"restore": [
{
"target": "critical-pkg",
"version": "1.2.3",
"extra_index_url": "https://custom-repo.example.com/simple"
}
]
}
}
```
- Installed: `{}` (package not present)
**Execution**:
```python
restored = batch.ensure_installed()
```
**Assertions**:
- pip install ["critical-pkg==1.2.3", "--extra-index-url", "https://custom-repo.example.com/simple"]
- `"critical-pkg" in restored`
**Expected Result**: Policy URL is used during restore
---
## 11. Large Batch and Performance Tests
### 11.1 Multiple Package Handling Tests (`test_large_batch.py`)
#### Test: `test_batch_with_20_packages`
**Purpose**: Verify cache efficiency when installing 20 packages in batch
**Setup**:
- 20 packages, each with different policy
**Execution**:
```python
with PipBatch() as batch:
for i in range(20):
batch.install(f"pkg-{i}")
```
**Assertions**:
- Count pip freeze calls
- First install: 1 call
- Subsequent installs: invalidate then re-call
- Total calls = 20 (1 per install)
**Expected Result**: Cache operates efficiently within batch
---
#### Test: `test_complex_policy_combinations`
**Purpose**: Verify complex policy combinations are all applied correctly
**Setup**:
- 20 packages:
- 5: uninstall policies
- 3: skip policies
- 4: force_version policies
- 2: replace policies
- 6: pin_dependencies policies
**Execution**:
```python
with PipBatch() as batch:
removed = batch.ensure_not_installed()
for pkg in packages:
batch.install(pkg)
restored = batch.ensure_installed()
```
**Assertions**:
- uninstall policies: 5 packages removed verified
- skip policies: 3 packages not installed verified
- force_version: 4 packages forced version verified
- replace: 2 packages replaced verified
- pin: 6 packages pinned dependencies verified
**Expected Result**: All policies are applied correctly
---
## 12. Logging and Debugging Tests
### 12.1 Reason Logging Tests (`test_reason_logging.py`)
#### Test: `test_skip_reason_logged`
**Purpose**: Verify reason from skip policy is logged
**Setup**:
- Policy:
```json
{
"torch": {
"apply_first_match": [
{"type": "skip", "reason": "Manual CUDA management required"}
]
}
}
```
**Execution**:
```python
result = batch.install("torch")
```
**Assertions**:
- `result is False`
- Info log: "Skipping installation of torch: Manual CUDA management required"
**Expected Result**: Reason is logged
---
#### Test: `test_all_policy_reasons_logged`
**Purpose**: Verify reasons from all policy types are logged
**Setup**:
- Policies with reasons: skip, force_version, replace, uninstall, restore, warn, pin_dependencies
**Execution**:
```python
# Execute all policy types
```
**Assertions**:
- Each policy execution logs reason in info or warning log
**Expected Result**: All reasons are appropriately logged
---
### 12.2 Policy Loading Logging Tests (`test_policy_loading_logs.py`)
#### Test: `test_policy_load_success_logged`
**Purpose**: Verify log on successful policy load
**Setup**:
- Policy file with 5 packages
**Execution**:
```python
policy = get_pip_policy()
```
**Assertions**:
- Debug log: "Loaded pip policy with 5 package policies"
**Expected Result**: Load success log recorded
---
#### Test: `test_cache_refresh_logged`
**Purpose**: Verify log on cache refresh
**Execution**:
```python
batch._refresh_installed_cache()
```
**Assertions**:
- Debug log: "Refreshed installed packages cache: N packages"
**Expected Result**: Cache refresh log recorded
---
## 13. Test Priorities and Execution Plan
### Priority 1 (Essential - Must Implement)
1.**Full workflow integration** (`test_uninstall_install_restore_workflow`)
2.**Complex dependency protection** (`test_dependency_version_protection_with_pin`)
3.**Environment corruption and recovery** (`test_package_deletion_and_restore`, `test_version_change_and_restore`)
4.**Pin failure retry** (`test_pin_failure_retry_without_pin_succeeds`)
5.**Cache consistency** (`test_cache_invalidation_across_workflow`)
### Priority 2 (Important - Implement If Possible)
6.**Policy priority** (`test_user_policy_overrides_base_policy`)
7.**Dependency chain** (`test_dependency_chain_with_click_colorama`) - Uses lightweight click+colorama instead of numba+numpy
8.**Platform conditions** (`test_linux_gpu_uses_gpu_package`) - Real onnxruntime-gpu scenario
9.**extra_index_url** (`test_parameter_url_overrides_policy_url`)
10.**Partial failure handling** (`test_ensure_not_installed_continues_on_individual_failure`)
### Priority 3 (Recommended - If Time Permits)
11.**Edge cases** (empty policies, malformed policies, self-reference)
12.**Large batch** (`test_batch_with_20_packages`)
13.**Logging verification** (reason, policy load, cache refresh)
---
## 14. Test Fixtures and Mocks
### 14.1 Common Fixtures (`conftest.py`)
```python
@pytest.fixture
def temp_policy_dir(tmp_path):
"""Create temporary directory for policy files"""
policy_dir = tmp_path / "policies"
policy_dir.mkdir()
return policy_dir
@pytest.fixture
def mock_manager_util(monkeypatch, temp_policy_dir):
"""Mock manager_util module"""
monkeypatch.setattr("pip_util.manager_util.comfyui_manager_path", str(temp_policy_dir))
monkeypatch.setattr("pip_util.manager_util.make_pip_cmd", lambda args: ["pip"] + args)
@pytest.fixture
def mock_context(monkeypatch, temp_policy_dir):
"""Mock context module"""
monkeypatch.setattr("pip_util.context.manager_files_path", str(temp_policy_dir))
@pytest.fixture
def mock_subprocess_success(monkeypatch):
"""Mock successful subprocess execution"""
def mock_run(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr("subprocess.run", mock_run)
@pytest.fixture
def mock_pip_freeze(monkeypatch):
"""Mock pip freeze output with lightweight real packages"""
def mock_run(cmd, **kwargs):
if "freeze" in cmd:
output = "urllib3==1.26.15\ncertifi==2023.7.22\ncharset-normalizer==3.2.0\ncolorama==0.4.6\n"
return subprocess.CompletedProcess(cmd, 0, output, "")
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr("subprocess.run", mock_run)
```
---
## 14.2 Test Packages (Lightweight Real PyPI Packages)
All tests use **real lightweight packages from PyPI** for realistic scenarios:
### Core Test Packages
| Package | Size | Version Used | Purpose in Tests |
|---------|------|--------------|------------------|
| **requests** | ~100KB | 2.31.0 | Main package to install with pinned dependencies |
| **urllib3** | ~100KB | 1.26.15 | Protected dependency (prevent upgrade to 2.x) |
| **certifi** | ~10KB | 2023.7.22 | SSL certificate package (pinned) |
| **charset-normalizer** | ~50KB | 3.2.0 | Character encoding (pinned) |
| **click** | ~100KB | 8.1.3 | CLI framework (force_version testing) |
| **colorama** | ~10KB | 0.4.6 | Terminal colors (dependency pinning) |
| **six** | ~10KB | 1.16.0 | Python 2/3 compatibility (restore testing) |
| **python-dateutil** | ~50KB | 2.8.2 | Package that may conflict with six |
| **attrs** | ~50KB | 23.1.0 | Class attributes (bystander package) |
| **packaging** | ~40KB | 23.1 | Version parsing (bystander package) |
### Why These Packages?
1. **Lightweight**: All packages < 200KB for fast testing
2. **Real Dependencies**: Actual PyPI package relationships
3. **Common Issues**: Test real-world scenarios:
- urllib3 1.x 2.x breaking change
- Package conflicts (six vs python-dateutil)
- Version pinning needs
4. **Fast Installation**: Quick test execution
### Test Scenario Mapping
**Dependency Protection Tests**:
- Install `requests` while protecting `urllib3`, `certifi`, `charset-normalizer`
- Prevent urllib3 upgrade to 2.x (breaking API changes)
**Dependency Chain Tests**:
- Install `click` with forced version when `colorama <0.5.0` detected
- Pin colorama to prevent incompatible upgrade
**Environment Recovery Tests**:
- Install `python-dateutil` which may remove `six`
- Restore `six` to 1.16.0
- Install `requests` which upgrades `urllib3` to 2.1.0
- Restore `urllib3` to 1.26.15
**Platform Condition Tests**:
- Install `onnxruntime-gpu` on Linux + GPU
- Install `onnxruntime` (CPU) on Windows or no GPU
### Package Relationship Diagram
```
requests 2.31.0
├── urllib3 (requires <2.0, >=1.21.1)
├── certifi (requires >=2017.4.17)
└── charset-normalizer (requires >=2, <4)
click 8.1.3
└── colorama (Windows only, optional)
python-dateutil 2.8.2
└── six (requires >=1.5)
```
---
## 15. Coverage Goals
### Target Coverage Metrics
- **Overall**: 80%
- **Core Functions**: 90%
- `get_pip_policy()`
- `install()`
- `ensure_not_installed()`
- `ensure_installed()`
- **Utility Functions**: 80%
- `_parse_package_spec()`
- `_evaluate_condition()`
- **Error Paths**: 100%
### Coverage Report Commands
```bash
# Run tests with coverage
pytest --cov=pip_util --cov-report=html --cov-report=term
# View detailed coverage
open htmlcov/index.html
```
---
## 16. Test Execution Order (TDD Workflow)
### Phase 1: Red Phase (Write Failing Tests)
1. Write policy loading tests
2. Write package spec parsing tests
3. Write condition evaluation tests
4. Run tests All fail (no implementation)
### Phase 2: Green Phase (Minimal Implementation)
1. Implement `get_pip_policy()` to pass tests
2. Implement `_parse_package_spec()` to pass tests
3. Implement `_evaluate_condition()` to pass tests
4. Run tests All pass
### Phase 3: Refactor Phase
1. Optimize code
2. Remove duplication
3. Improve readability
4. Run tests All still pass
### Phase 4-6: Repeat for Remaining Features
- Repeat Red-Green-Refactor for pip freeze caching
- Repeat for install() method
- Repeat for batch operations
---
## 17. CI/CD Integration
### Pre-commit Hooks
```yaml
repos:
- repo: local
hooks:
- id: pytest-check
name: pytest
entry: pytest
language: system
pass_filenames: false
always_run: true
```
### GitHub Actions Workflow
```yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: pip install -r requirements-test.txt
- name: Run tests
run: pytest --cov=pip_util --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v2
```
---
## 18. Test Maintenance Guidelines
### When to Update Tests
- **Breaking changes**: Update affected tests immediately
- **New features**: Write tests first (TDD)
- **Bug fixes**: Add regression test before fix
### Test Naming Convention
- `test_<function>_<scenario>_<expected_result>`
- Example: `test_install_skip_policy_returns_false`
### Test Documentation
- Each test has clear docstring
- Purpose, setup, execution, assertions documented
- Edge cases explicitly noted
---
## 19. Performance Test Considerations
### Performance Benchmarks
```python
def test_get_pip_policy_performance():
"""Policy loading should complete in <100ms"""
import time
start = time.time()
get_pip_policy()
duration = time.time() - start
assert duration < 0.1, f"Policy loading took {duration}s, expected <0.1s"
def test_pip_freeze_caching_performance():
"""Cached access should be >50% faster"""
# Measure first call (with pip freeze)
# Measure second call (from cache)
# Assert second is >50% faster
```
---
## 20. Success Criteria
### Test Suite Completeness
- All public methods have tests
- All error paths have tests
- Edge cases covered
- Integration tests verify behavior
- E2E tests verify workflows
### Quality Metrics
- Coverage 80%
- All tests pass
- No flaky tests
- Tests run in <30 seconds
- Clear documentation for all tests