From 47af2c70962d16dfb5a590aded874bf15c410941 Mon Sep 17 00:00:00 2001 From: mzfive Date: Thu, 14 Aug 2025 14:06:26 +0200 Subject: [PATCH] Release MLX Knife 1.0-rc3 Resolves GitHub Issues #1, #2, #3: - Fix #1: Partial name filtering for `mlxk list` command - Fix #2: Fuzzy matching for single-model commands - Fix #3: Default behavior for `mlxk health` (no --all flag required) - Expanded test suite to 104/104 tests passing --- CHANGELOG.md | 20 ++++ README.md | 4 +- SECURITY.md | 2 +- TESTING.md | 22 ++--- mlx_knife/__init__.py | 2 +- mlx_knife/cache_utils.py | 171 ++++++++++++++++++++++++--------- mlx_knife/cli.py | 8 +- tests/unit/test_cache_utils.py | 93 +++++++++++++++++- tests/unit/test_cli.py | 65 ++++++++++++- 9 files changed, 317 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d47dd4d..9d7b06c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.0-rc3] - 2025-08-14 + +### Added +- **Issue 1**: Partial name filtering for `mlxk list` command (e.g., `mlxk list Phi-3`) +- **Issue 2**: Fuzzy matching for single-model commands (`mlxk show Phi-3`, `mlxk run Phi-3`) +- **Issue 3**: Default `mlxk health` behavior (no `--all` flag required) +- Comprehensive test coverage for all new fuzzy matching features +- Smart ambiguity resolution with helpful error messages + +### Enhanced +- All single-model commands now support partial name matching +- Case-insensitive model name searching +- Improved user experience with intelligent model resolution +- Expanded test suite from 96 to 104 tests (104/104 passing ✅) + +### Fixed +- Health command now works without requiring `--all` flag +- Better error handling for ambiguous model specifications +- Enhanced fuzzy matching logic with fallback mechanisms + ## [1.0-rc2] - 2025-08-13 ### Enhanced diff --git a/README.md b/README.md index 05f8f92..d9a43d1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A lightweight, ollama-like CLI for managing and running MLX models on Apple Silicon. **Designed for personal, local use** - perfect for individual developers and researchers working with MLX models. -**Current Version**: 1.0-rc2 (August 2025) +**Current Version**: 1.0-rc3 (August 2025) [![GitHub Release](https://img.shields.io/github/v/release/mzau/mlx-knife)](https://github.com/mzau/mlx-knife/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -416,5 +416,5 @@ Copyright (c) 2025 The BROKE team đŸĻĢ

Made with â¤ī¸ by The BROKE team BROKE Logo
- Version 1.0-rc2 | August 2025 + Version 1.0-rc3 | August 2025

\ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 6bc5390..5a9d967 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -98,7 +98,7 @@ mlxk server --host 0.0.0.0 --port 8000 | Version | Supported | | ------- | ------------------ | -| 1.0-rc2 | :white_check_mark: | +| 1.0-rc3 | :white_check_mark: | | < 1.0 | :x: | ## Additional Resources diff --git a/TESTING.md b/TESTING.md index 1ef0da8..e874320 100644 --- a/TESTING.md +++ b/TESTING.md @@ -141,12 +141,12 @@ pytest --durations=10 pytest -n auto ``` -## Test Results Summary (1.0-rc2) +## Test Results Summary (1.0-rc3) ### ✅ Current Test Status (August 2025) ``` -Total Tests: 96/96 passing (100% ✅) +Total Tests: 104/104 passing (100% ✅) ├── ✅ Integration Tests: 61 passing ├── ✅ Unit Tests: 25 passing └── ✅ Real MLX Model Tests: All passing with Phi-3-mini @@ -173,7 +173,7 @@ Total Tests: 96/96 passing (100% ✅) ## Python Version Compatibility ### Compatibility Status -MLX Knife 1.0-rc2 is fully compatible with Python 3.9-3.13. Comprehensive verification completed with 96/96 tests passing on all supported versions. +MLX Knife 1.0-rc3 is fully compatible with Python 3.9-3.13. Comprehensive verification completed with 104/104 tests passing on all supported versions. ### Manual Multi-Python Testing @@ -195,11 +195,11 @@ deactivate && rm -rf test_39 | Python Version | Status | Tests Passing | |----------------|--------|---------------| -| 3.9.6 (macOS) | ✅ Verified | 96/96 | -| 3.10.x | ✅ Verified | 96/96 | -| 3.11.x | ✅ Verified | 96/96 | -| 3.12.x | ✅ Verified | 96/96 | -| 3.13.x | ✅ Verified | 96/96 | +| 3.9.6 (macOS) | ✅ Verified | 104/104 | +| 3.10.x | ✅ Verified | 104/104 | +| 3.11.x | ✅ Verified | 104/104 | +| 3.12.x | ✅ Verified | 104/104 | +| 3.13.x | ✅ Verified | 104/104 | All versions tested with real MLX model execution (Phi-3-mini-4k-instruct-4bit). @@ -339,16 +339,16 @@ When submitting PRs, please include: Platform: macOS 14.5, M2 Pro Python: 3.11.6 Model: Phi-3-mini-4k-instruct-4bit - Results: 96/96 tests passed + Results: 104/104 tests passed ``` 3. **Any issues encountered** and how you resolved them ## Summary -**MLX Knife 1.0-rc2 Testing Status:** +**MLX Knife 1.0-rc3 Testing Status:** -✅ **Production Ready** - 96/96 tests passing +✅ **Production Ready** - 104/104 tests passing ✅ **Multi-Python Support** - Python 3.9-3.13 verified ✅ **Code Quality** - ruff/mypy integration working ✅ **Real Model Testing** - Phi-3-mini execution confirmed diff --git a/mlx_knife/__init__.py b/mlx_knife/__init__.py index 8339512..30af410 100644 --- a/mlx_knife/__init__.py +++ b/mlx_knife/__init__.py @@ -4,7 +4,7 @@ A lightweight, ollama-like CLI for managing and running MLX models on Apple Sili Provides native MLX execution with streaming output and interactive chat capabilities. """ -__version__ = "1.0-rc2" +__version__ = "1.0-rc3" __author__ = "The BROKE team" __email__ = "broke@gmx.eu" __license__ = "MIT" diff --git a/mlx_knife/cache_utils.py b/mlx_knife/cache_utils.py index 122978f..32ee594 100644 --- a/mlx_knife/cache_utils.py +++ b/mlx_knife/cache_utils.py @@ -47,6 +47,57 @@ def expand_model_name(model_name): return f"mlx-community/{model_name}" return model_name +def find_matching_models(pattern): + """Find models that match a partial pattern. Returns a list of (model_dir, hf_name) tuples.""" + all_models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")] + matches = [] + + for model_dir in all_models: + hf_name = cache_dir_to_hf(model_dir.name) + # Check if the pattern appears in the model name (case insensitive) + if pattern.lower() in hf_name.lower(): + matches.append((model_dir, hf_name)) + + return matches + +def resolve_single_model(model_spec): + """ + Resolve a model spec to a single model, supporting fuzzy matching. + Returns (model_path, model_name, commit_hash) or (None, None, None) if failed. + Prints appropriate error messages for ambiguous matches. + """ + # Parse the model spec (handles @commit_hash syntax) + model_name, commit_hash = parse_model_spec(model_spec) + + # Try exact match first + base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name) + if base_cache_dir.exists(): + return get_model_path(model_spec) + + # Extract the base name (without @commit_hash) for fuzzy matching + base_spec = model_spec.split('@')[0] if '@' in model_spec else model_spec + + # Try fuzzy matching + matches = find_matching_models(base_spec) + + if not matches: + print(f"No models found matching '{base_spec}'!") + return None, None, None + elif len(matches) == 1: + # Unambiguous match - use the found model with the original commit hash (if any) + found_model_dir, found_hf_name = matches[0] + if commit_hash: + resolved_spec = f"{found_hf_name}@{commit_hash}" + else: + resolved_spec = found_hf_name + return get_model_path(resolved_spec) + else: + # Multiple matches - show error with suggestions + print(f"Multiple models match '{base_spec}'. Please be more specific:") + for _, hf_name in sorted(matches, key=lambda x: x[1]): + print(f" {hf_name}") + return None, None, None + def get_model_path(model_spec): model_name, commit_hash = parse_model_spec(model_spec) base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name) @@ -192,36 +243,39 @@ def check_lfs_corruption(model_path): return True, "No LFS corruption detected" def check_model_health(model_spec): - model_path, model_name, commit_hash = get_model_path(model_spec) + model_path, model_name, commit_hash = resolve_single_model(model_spec) if not model_path: - # Check if base directory exists but is corrupted (no snapshots) - base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name) - if base_cache_dir.exists(): - print(f"[ERROR] Model '{model_spec}' directory exists but no snapshots found!") - confirm = input("Model appears corrupted. Delete? [y/N] ") - if confirm.lower() == "y": - import errno - import shutil - try: - shutil.rmtree(base_cache_dir) - print(f"Model {model_name} deleted.") - except PermissionError as e: - print(f"[ERROR] Permission denied: Cannot delete {e.filename}") - print(" Try running with appropriate permissions or manually delete the directory.") - except OSError as e: - if e.errno == errno.ENOTEMPTY: - print(f"[ERROR] Directory not empty: {e.filename}") - print(" Another process may be using this model.") - elif e.errno == errno.EACCES: - print(f"[ERROR] Access denied: {e.filename}") - else: - print(f"[ERROR] OS Error while deleting: {e}") - except Exception as e: - print(f"[ERROR] Unexpected error while deleting: {type(e).__name__}: {e}") - return False - else: - print(f"[ERROR] Model '{model_spec}' not found!") - return False + # resolve_single_model already printed the appropriate error message + # Try one more fallback: check if this is an exact model name that exists but is corrupted + try: + fallback_model_name, fallback_commit_hash = parse_model_spec(model_spec) + base_cache_dir = MODEL_CACHE / hf_to_cache_dir(fallback_model_name) + if base_cache_dir.exists(): + print(f"[ERROR] Model '{model_spec}' directory exists but no snapshots found!") + confirm = input("Model appears corrupted. Delete? [y/N] ") + if confirm.lower() == "y": + import errno + import shutil + try: + shutil.rmtree(base_cache_dir) + print(f"Model {fallback_model_name} deleted.") + except PermissionError as e: + print(f"[ERROR] Permission denied: Cannot delete {e.filename}") + print(" Try running with appropriate permissions or manually delete the directory.") + except OSError as e: + if e.errno == errno.ENOTEMPTY: + print(f"[ERROR] Directory not empty: {e.filename}") + print(" Another process may be using this model.") + elif e.errno == errno.EACCES: + print(f"[ERROR] Access denied: {e.filename}") + else: + print(f"[ERROR] OS Error while deleting: {e}") + except Exception as e: + print(f"[ERROR] Unexpected error while deleting: {type(e).__name__}: {e}") + except: + # If even fallback parsing fails, just return + pass + return False print(f"Checking model: {model_name}") if commit_hash: print(f"Hash: {commit_hash}") @@ -339,15 +393,28 @@ def check_all_models_health(): def list_models(show_all=False, framework_filter=None, show_health=False, single_model=None, verbose=False): if single_model: - # Expand the model name if needed + # Try exact match first expanded_model = expand_model_name(single_model) model_dir = MODEL_CACHE / hf_to_cache_dir(expanded_model) - if not model_dir.exists(): - print(f"Model '{single_model}' not found!") - return - - models = [model_dir] + if model_dir.exists(): + models = [model_dir] + else: + # If exact match fails, do partial name matching + all_models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")] + matching_models = [] + + for model_dir in all_models: + hf_name = cache_dir_to_hf(model_dir.name) + # Check if the pattern appears in the model name (case insensitive) + if single_model.lower() in hf_name.lower(): + matching_models.append(model_dir) + + if not matching_models: + print(f"No models found matching '{single_model}'!") + return + + models = matching_models else: models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")] if not models: @@ -406,9 +473,8 @@ def run_model(model_spec, prompt=None, interactive=False, temperature=0.7, repetition_penalty: Penalty for repeated tokens stream: Whether to stream output """ - model_path, model_name, commit_hash = get_model_path(model_spec) + model_path, model_name, commit_hash = resolve_single_model(model_spec) if not model_path: - print(f"Model '{model_spec}' not found!") print(f"Use: mlxk pull {model_spec}") sys.exit(1) @@ -451,10 +517,9 @@ def run_model(model_spec, prompt=None, interactive=False, temperature=0.7, def show_model(model_spec, show_files=False, show_config=False): """Show detailed information about a specific model.""" - model_path, model_name, commit_hash = get_model_path(model_spec) + model_path, model_name, commit_hash = resolve_single_model(model_spec) if not model_path: - print(f"[ERROR] Model '{model_spec}' not found!") return False # Basic information @@ -661,20 +726,30 @@ def show_model(model_spec, show_files=False, show_config=False): def rm_model(model_spec): original_spec = model_spec - model_name, commit_hash = parse_model_spec(model_spec) - # Confirm on auto-expansion - if "/" not in original_spec.split("@")[0] and "/" in model_name: - confirm = input(f"Delete '{model_name}'? [Y/n] ") + + # First try to resolve using fuzzy matching + resolved_path, resolved_name, resolved_hash = resolve_single_model(model_spec) + + if not resolved_path: + # resolve_single_model already printed the error message + return + + # Use the resolved model name for deletion + model_name = resolved_name + commit_hash = resolved_hash + + # Confirm on auto-expansion (if the resolved name is different from input) + base_spec = original_spec.split("@")[0] if "@" in original_spec else original_spec + if base_spec != model_name and "/" not in base_spec: + confirm = input(f"Delete '{model_name}' (matched from '{base_spec}')? [Y/n] ") if confirm.lower() == "n": print("Delete aborted.") return + base_cache_dir = MODEL_CACHE / hf_to_cache_dir(model_name) + # This should exist since resolve_single_model succeeded, but double-check if not base_cache_dir.exists(): - print(f"Model '{model_name}' not found!") - print("\nAvailable models:") - models = [d for d in MODEL_CACHE.iterdir() if d.name.startswith("models--")] - for m in sorted(models): - print(f" {cache_dir_to_hf(m.name)}") + print(f"[ERROR] Model directory disappeared: {model_name}") return # Specific hash to delete? if commit_hash: diff --git a/mlx_knife/cli.py b/mlx_knife/cli.py index a14b231..48c0994 100644 --- a/mlx_knife/cli.py +++ b/mlx_knife/cli.py @@ -102,13 +102,11 @@ def main(): elif args.cmd == "rm": rm_model(args.model_spec) elif args.cmd == "health": - if args.all: - check_all_models_health() - elif args.model_spec: + if args.model_spec: check_model_health(args.model_spec) else: - print("Error: --all or model_spec required") - parser.print_help() + # Default to checking all models if no specific model is provided + check_all_models_health() elif args.cmd == "show": show_model(args.model_spec, show_files=args.files, show_config=args.config) elif args.cmd == "server": diff --git a/tests/unit/test_cache_utils.py b/tests/unit/test_cache_utils.py index 8e44bb4..a3c0623 100644 --- a/tests/unit/test_cache_utils.py +++ b/tests/unit/test_cache_utils.py @@ -20,7 +20,9 @@ from mlx_knife.cache_utils import ( cache_dir_to_hf, is_model_healthy, detect_framework, - list_models + list_models, + find_matching_models, + resolve_single_model ) @@ -329,6 +331,95 @@ class TestModelListing: pytest.fail(f"Model listing with parameters failed: {e}") +class TestPartialNameFiltering: + """Test partial name filtering for list command (Issue 1).""" + + def test_find_matching_models_function(self): + """Test the find_matching_models helper function.""" + with patch('mlx_knife.cache_utils.MODEL_CACHE') as mock_cache: + # Mock some model directories + mock_models = [ + MagicMock(name="models--mlx-community--Phi-3-mini"), + MagicMock(name="models--mlx-community--Phi-3-medium"), + MagicMock(name="models--other--Llama-3-8B"), + ] + + for i, mock_model in enumerate(mock_models): + mock_model.name = f"models--{'mlx-community' if i < 2 else 'other'}--{'Phi-3-mini' if i == 0 else 'Phi-3-medium' if i == 1 else 'Llama-3-8B'}" + + mock_cache.iterdir.return_value = mock_models + + # Test finding Phi-3 models + matches = find_matching_models("Phi-3") + assert len(matches) == 2 + + # Test finding non-existent model + matches = find_matching_models("nonexistent") + assert len(matches) == 0 + + def test_partial_matching_basic_functionality(self): + """Test basic partial matching logic without complex mocking.""" + # Simple functional test of the helper functions + try: + # These functions exist and can be called + assert callable(find_matching_models) + # Function handles empty input gracefully + matches = find_matching_models("") + assert isinstance(matches, list) + except Exception as e: + pytest.fail(f"Basic functionality test failed: {e}") + + +class TestSingleModelFuzzyMatching: + """Test fuzzy matching for single-model commands (Issue 2).""" + + def test_resolve_single_model_function_exists(self): + """Test that resolve_single_model function exists and is callable.""" + try: + assert callable(resolve_single_model) + # Function handles invalid input gracefully + result = resolve_single_model("definitely-nonexistent-model-12345") + assert isinstance(result, tuple) + assert len(result) == 3 + except Exception as e: + pytest.fail(f"Function existence test failed: {e}") + + @patch('mlx_knife.cache_utils.get_model_path') + @patch('mlx_knife.cache_utils.find_matching_models') + def test_resolve_single_model_ambiguous_fuzzy(self, mock_find, mock_get_path, capsys): + """Test ambiguous fuzzy match shows error.""" + # Mock exact match fails, fuzzy finds multiple matches + mock_get_path.return_value = (None, None, None) + mock_find.return_value = [ + (MagicMock(), "model-1"), + (MagicMock(), "model-2") + ] + + result = resolve_single_model("partial") + assert result[0] is None # Should fail + + # Check that error message was printed + captured = capsys.readouterr() + assert "Multiple models match" in captured.out + assert "model-1" in captured.out + assert "model-2" in captured.out + + @patch('mlx_knife.cache_utils.get_model_path') + @patch('mlx_knife.cache_utils.find_matching_models') + def test_resolve_single_model_no_match(self, mock_find, mock_get_path, capsys): + """Test no match shows appropriate error.""" + # Mock both exact and fuzzy matching fail + mock_get_path.return_value = (None, None, None) + mock_find.return_value = [] + + result = resolve_single_model("nonexistent") + assert result[0] is None # Should fail + + # Check error message + captured = capsys.readouterr() + assert "No models found matching" in captured.out + + # Add pytest fixture at module level @pytest.fixture def temp_cache_dir(): diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index d9df18e..9082064 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -139,4 +139,67 @@ class TestErrorHandling: # Exit is acceptable for some CLI implementations pass except Exception as e: - pytest.fail(f"Basic command robustness test failed: {e}") \ No newline at end of file + pytest.fail(f"Basic command robustness test failed: {e}") + + +class TestHealthCommandDefaultBehavior: + """Test health command default behavior (Issue 3).""" + + @patch('mlx_knife.cli.check_all_models_health') + def test_health_command_without_args_calls_all(self, mock_check_all): + """Test that 'mlxk health' (no args) calls check_all_models_health.""" + mock_check_all.return_value = True + + try: + with patch('sys.argv', ['mlxk', 'health']): + main() + + # Should have called check_all_models_health + assert mock_check_all.called + mock_check_all.assert_called_once() + except SystemExit: + # Exit is acceptable after running the command + assert mock_check_all.called + except Exception as e: + pytest.fail(f"Health command default behavior test failed: {e}") + + @patch('mlx_knife.cli.check_model_health') + @patch('mlx_knife.cli.check_all_models_health') + def test_health_command_with_specific_model(self, mock_check_all, mock_check_specific): + """Test that 'mlxk health model-name' calls check_model_health.""" + mock_check_specific.return_value = True + + try: + with patch('sys.argv', ['mlxk', 'health', 'some-model']): + main() + + # Should have called check_model_health with the specific model + assert mock_check_specific.called + mock_check_specific.assert_called_once_with('some-model') + + # Should NOT have called check_all_models_health + assert not mock_check_all.called + except SystemExit: + # Exit is acceptable after running the command + assert mock_check_specific.called + assert not mock_check_all.called + except Exception as e: + pytest.fail(f"Health command specific model test failed: {e}") + + @patch('mlx_knife.cli.check_all_models_health') + def test_health_command_backward_compatibility_with_all_flag(self, mock_check_all): + """Test that 'mlxk health --all' still works for backward compatibility.""" + mock_check_all.return_value = True + + try: + with patch('sys.argv', ['mlxk', 'health', '--all']): + main() + + # Should have called check_all_models_health + assert mock_check_all.called + mock_check_all.assert_called_once() + except SystemExit: + # Exit is acceptable after running the command + assert mock_check_all.called + except Exception as e: + pytest.fail(f"Health command --all flag test failed: {e}") \ No newline at end of file