loomcast 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. loomcast-0.1.0/LICENSE +20 -0
  2. loomcast-0.1.0/PKG-INFO +112 -0
  3. loomcast-0.1.0/README.md +79 -0
  4. loomcast-0.1.0/loomcast/__init__.py +130 -0
  5. loomcast-0.1.0/loomcast/adapters/__init__.py +13 -0
  6. loomcast-0.1.0/loomcast/adapters/cache/__init__.py +3 -0
  7. loomcast-0.1.0/loomcast/adapters/cache/store.py +56 -0
  8. loomcast-0.1.0/loomcast/adapters/catalog/__init__.py +4 -0
  9. loomcast-0.1.0/loomcast/adapters/catalog/dynamic.py +154 -0
  10. loomcast-0.1.0/loomcast/adapters/catalog/static.py +68 -0
  11. loomcast-0.1.0/loomcast/adapters/downloader/__init__.py +13 -0
  12. loomcast-0.1.0/loomcast/adapters/downloader/progress.py +112 -0
  13. loomcast-0.1.0/loomcast/adapters/runtimes/__init__.py +9 -0
  14. loomcast-0.1.0/loomcast/adapters/runtimes/_openai_compat.py +33 -0
  15. loomcast-0.1.0/loomcast/adapters/runtimes/llamacpp.py +73 -0
  16. loomcast-0.1.0/loomcast/adapters/runtimes/lmstudio.py +71 -0
  17. loomcast-0.1.0/loomcast/adapters/runtimes/ollama.py +117 -0
  18. loomcast-0.1.0/loomcast/cli/__init__.py +3 -0
  19. loomcast-0.1.0/loomcast/cli/main.py +276 -0
  20. loomcast-0.1.0/loomcast/common/__init__.py +30 -0
  21. loomcast-0.1.0/loomcast/common/config.py +93 -0
  22. loomcast-0.1.0/loomcast/common/exceptions.py +35 -0
  23. loomcast-0.1.0/loomcast/common/logging.py +21 -0
  24. loomcast-0.1.0/loomcast/common/paths.py +31 -0
  25. loomcast-0.1.0/loomcast/core/__init__.py +5 -0
  26. loomcast-0.1.0/loomcast/core/manager.py +166 -0
  27. loomcast-0.1.0/loomcast/core/verifier.py +68 -0
  28. loomcast-0.1.0/loomcast/domain/__init__.py +8 -0
  29. loomcast-0.1.0/loomcast/domain/entities.py +45 -0
  30. loomcast-0.1.0/loomcast/ports/__init__.py +9 -0
  31. loomcast-0.1.0/loomcast/ports/cache.py +24 -0
  32. loomcast-0.1.0/loomcast/ports/catalog.py +21 -0
  33. loomcast-0.1.0/loomcast/ports/runtime.py +32 -0
  34. loomcast-0.1.0/loomcast.egg-info/PKG-INFO +112 -0
  35. loomcast-0.1.0/loomcast.egg-info/SOURCES.txt +51 -0
  36. loomcast-0.1.0/loomcast.egg-info/dependency_links.txt +1 -0
  37. loomcast-0.1.0/loomcast.egg-info/entry_points.txt +2 -0
  38. loomcast-0.1.0/loomcast.egg-info/requires.txt +16 -0
  39. loomcast-0.1.0/loomcast.egg-info/top_level.txt +1 -0
  40. loomcast-0.1.0/pyproject.toml +60 -0
  41. loomcast-0.1.0/setup.cfg +4 -0
  42. loomcast-0.1.0/tests/conftest.py +21 -0
  43. loomcast-0.1.0/tests/test_adapters.py +169 -0
  44. loomcast-0.1.0/tests/test_cache.py +77 -0
  45. loomcast-0.1.0/tests/test_catalog.py +68 -0
  46. loomcast-0.1.0/tests/test_catalog_dynamic.py +141 -0
  47. loomcast-0.1.0/tests/test_cli.py +122 -0
  48. loomcast-0.1.0/tests/test_config.py +117 -0
  49. loomcast-0.1.0/tests/test_domain.py +43 -0
  50. loomcast-0.1.0/tests/test_downloader.py +98 -0
  51. loomcast-0.1.0/tests/test_manager.py +212 -0
  52. loomcast-0.1.0/tests/test_ports.py +51 -0
  53. loomcast-0.1.0/tests/test_verifier.py +144 -0
loomcast-0.1.0/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Loomcast Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: loomcast
3
+ Version: 0.1.0
4
+ Summary: Python SDK and CLI for seamlessly managing local LLM runtimes (Ollama, LM Studio, llama.cpp)
5
+ Author: Loomcast Contributors
6
+ License: MIT
7
+ Keywords: llm,ollama,lmstudio,llamacpp,ai,sdk,cli,local-ai
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: typer>=0.9.0
20
+ Requires-Dist: pydantic>=2.0.0
21
+ Requires-Dist: httpx>=0.24.0
22
+ Requires-Dist: platformdirs>=3.0.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: tomli-w>=1.0.0
25
+ Requires-Dist: tomli>=2.0.0; python_version < "3.11"
26
+ Requires-Dist: typing-extensions>=4.5.0; python_version < "3.11"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
29
+ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
31
+ Requires-Dist: build>=1.0.0; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # Loomcast
35
+
36
+ [![CI Status](https://img.shields.io/github/actions/workflow/status/<owner>/<repo>/ci.yml?branch=main&label=CI)](https://github.com/<owner>/<repo>/actions/workflows/ci.yml)
37
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
38
+ [![Python Version](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org)
39
+
40
+ Python SDK and CLI for seamlessly managing local LLM runtimes (**Ollama**, **LM Studio**, and **llama.cpp**).
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ pip install -e .
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ### CLI Usage
51
+
52
+ ```bash
53
+ # Check status of local runtimes
54
+ loomcast status
55
+
56
+ # List installed models
57
+ loomcast list
58
+
59
+ # Search catalog for models
60
+ loomcast search code
61
+
62
+ # Recommend models for a specific task or tag
63
+ loomcast recommend coding
64
+
65
+ # Batch install all models in a category
66
+ loomcast install-category code --backend ollama
67
+
68
+ # Send a prompt to a model
69
+ loomcast chat llama3 "Hello world!" --backend ollama
70
+
71
+ # Verify installed model integrity
72
+ loomcast verify llama3 --backend ollama
73
+ ```
74
+
75
+ ### Python SDK Usage
76
+
77
+ ```python
78
+ import loomcast
79
+
80
+ # Initialize manager
81
+ manager = loomcast.LoomcastManager()
82
+
83
+ # Search catalog
84
+ results = loomcast.search("code")
85
+ for model in results:
86
+ print(model.name, model.description)
87
+
88
+ # Recommend models for a task
89
+ recs = loomcast.recommend("coding")
90
+
91
+ # Batch install category
92
+ installed, failed = loomcast.install_category("code", runtime_type="ollama")
93
+
94
+ # Send chat messages
95
+ response = loomcast.chat("llama3", [{"role": "user", "content": "Hello!"}], runtime_type="ollama")
96
+ print(response)
97
+
98
+ # Verify model integrity
99
+ verification = loomcast.verify("llama3", runtime_type="ollama")
100
+ print(f"Verified: {verification.verified}, Digest: {verification.digest}")
101
+ ```
102
+
103
+ ## Documentation
104
+
105
+ Detailed documentation guides are available in the [`docs/`](docs/) directory:
106
+
107
+ - [Prise en main (Getting Started)](docs/getting-started.md)
108
+ - [Configuration](docs/configuration.md)
109
+ - [Runtimes Supportés](docs/runtimes.md)
110
+ - [Extension & Architecture](docs/extending.md)
111
+ - [Référence CLI](docs/cli-reference.md)
112
+
@@ -0,0 +1,79 @@
1
+ # Loomcast
2
+
3
+ [![CI Status](https://img.shields.io/github/actions/workflow/status/<owner>/<repo>/ci.yml?branch=main&label=CI)](https://github.com/<owner>/<repo>/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![Python Version](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org)
6
+
7
+ Python SDK and CLI for seamlessly managing local LLM runtimes (**Ollama**, **LM Studio**, and **llama.cpp**).
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install -e .
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ### CLI Usage
18
+
19
+ ```bash
20
+ # Check status of local runtimes
21
+ loomcast status
22
+
23
+ # List installed models
24
+ loomcast list
25
+
26
+ # Search catalog for models
27
+ loomcast search code
28
+
29
+ # Recommend models for a specific task or tag
30
+ loomcast recommend coding
31
+
32
+ # Batch install all models in a category
33
+ loomcast install-category code --backend ollama
34
+
35
+ # Send a prompt to a model
36
+ loomcast chat llama3 "Hello world!" --backend ollama
37
+
38
+ # Verify installed model integrity
39
+ loomcast verify llama3 --backend ollama
40
+ ```
41
+
42
+ ### Python SDK Usage
43
+
44
+ ```python
45
+ import loomcast
46
+
47
+ # Initialize manager
48
+ manager = loomcast.LoomcastManager()
49
+
50
+ # Search catalog
51
+ results = loomcast.search("code")
52
+ for model in results:
53
+ print(model.name, model.description)
54
+
55
+ # Recommend models for a task
56
+ recs = loomcast.recommend("coding")
57
+
58
+ # Batch install category
59
+ installed, failed = loomcast.install_category("code", runtime_type="ollama")
60
+
61
+ # Send chat messages
62
+ response = loomcast.chat("llama3", [{"role": "user", "content": "Hello!"}], runtime_type="ollama")
63
+ print(response)
64
+
65
+ # Verify model integrity
66
+ verification = loomcast.verify("llama3", runtime_type="ollama")
67
+ print(f"Verified: {verification.verified}, Digest: {verification.digest}")
68
+ ```
69
+
70
+ ## Documentation
71
+
72
+ Detailed documentation guides are available in the [`docs/`](docs/) directory:
73
+
74
+ - [Prise en main (Getting Started)](docs/getting-started.md)
75
+ - [Configuration](docs/configuration.md)
76
+ - [Runtimes Supportés](docs/runtimes.md)
77
+ - [Extension & Architecture](docs/extending.md)
78
+ - [Référence CLI](docs/cli-reference.md)
79
+
@@ -0,0 +1,130 @@
1
+ from typing import Any, Dict, List, Optional, Tuple, Union
2
+
3
+ from loomcast.adapters.downloader.progress import (
4
+ NullProgressReporter,
5
+ ProgressReporter,
6
+ RichProgressReporter,
7
+ )
8
+ from loomcast.common.config import LoomcastConfig
9
+ from loomcast.common.exceptions import (
10
+ CacheError,
11
+ ConfigurationError,
12
+ LoomcastError,
13
+ ModelNotFoundError,
14
+ RuntimeExecutionError,
15
+ RuntimeUnavailableError,
16
+ )
17
+ from loomcast.adapters.runtimes import LlamaCppRuntime
18
+ from loomcast.core.manager import LoomcastManager
19
+ from loomcast.domain.entities import CatalogEntry, ChatMessage, Model, RuntimeType, VerificationResult
20
+
21
+ _default_manager: Optional[LoomcastManager] = None
22
+
23
+
24
+ def get_manager(
25
+ config: Optional[LoomcastConfig] = None,
26
+ refresh: bool = False,
27
+ ) -> LoomcastManager:
28
+ """Get or create global default LoomcastManager instance."""
29
+ global _default_manager
30
+ if _default_manager is None or refresh:
31
+ _default_manager = LoomcastManager(config=config)
32
+ return _default_manager
33
+
34
+
35
+ def search(query: str) -> List[CatalogEntry]:
36
+ """Search model catalog by query string."""
37
+ return get_manager().search(query)
38
+
39
+
40
+ def recommend(task: str) -> List[CatalogEntry]:
41
+ """Recommend model catalog entries matching a task or tag."""
42
+ return get_manager().recommend(task)
43
+
44
+
45
+ def install_category(
46
+ category: str,
47
+ runtime_type: Optional[Union[RuntimeType, str]] = None,
48
+ max_concurrent: int = 3,
49
+ progress: Optional[ProgressReporter] = None,
50
+ ) -> Tuple[List[str], List[str]]:
51
+ """Batch install all models in a category."""
52
+ reporter = progress if progress is not None else NullProgressReporter()
53
+ return get_manager().install_category(
54
+ category,
55
+ runtime_type=runtime_type,
56
+ max_concurrent=max_concurrent,
57
+ progress=reporter,
58
+ )
59
+
60
+
61
+ def verify(
62
+ model: str,
63
+ runtime_type: Optional[Union[RuntimeType, str]] = None,
64
+ ) -> VerificationResult:
65
+ """Verify installed model integrity."""
66
+ return get_manager().verify(model, runtime_type=runtime_type)
67
+
68
+
69
+ def chat(
70
+ model: str,
71
+ messages: List[Dict[str, Any]],
72
+ runtime_type: Optional[Union[RuntimeType, str]] = None,
73
+ ) -> str:
74
+ """Send chat messages to a local LLM model."""
75
+ return get_manager().chat(model, messages, runtime_type=runtime_type)
76
+
77
+
78
+ def install(
79
+ model: str,
80
+ runtime_type: Optional[Union[RuntimeType, str]] = None,
81
+ progress: Optional[ProgressReporter] = None,
82
+ ) -> None:
83
+ """Install a model into the local runtime."""
84
+ reporter = progress if progress is not None else NullProgressReporter()
85
+ get_manager().install(model, runtime_type=runtime_type, progress=reporter)
86
+
87
+
88
+ def list_installed(
89
+ runtime_type: Optional[Union[RuntimeType, str]] = None,
90
+ ) -> List[str]:
91
+ """List installed models in the local runtime."""
92
+ return get_manager().list_installed(runtime_type=runtime_type)
93
+
94
+
95
+ def is_model_installed(
96
+ model: str,
97
+ runtime_type: Optional[Union[RuntimeType, str]] = None,
98
+ ) -> bool:
99
+ """Check if a model is installed in the local runtime."""
100
+ return get_manager().is_model_installed(model, runtime_type=runtime_type)
101
+
102
+
103
+ __all__ = [
104
+ "LoomcastManager",
105
+ "get_manager",
106
+ "search",
107
+ "recommend",
108
+ "install_category",
109
+ "verify",
110
+ "chat",
111
+ "install",
112
+ "list_installed",
113
+ "is_model_installed",
114
+ "ProgressReporter",
115
+ "RichProgressReporter",
116
+ "NullProgressReporter",
117
+ "LlamaCppRuntime",
118
+ "LoomcastConfig",
119
+ "LoomcastError",
120
+ "RuntimeUnavailableError",
121
+ "ModelNotFoundError",
122
+ "ConfigurationError",
123
+ "RuntimeExecutionError",
124
+ "CacheError",
125
+ "RuntimeType",
126
+ "Model",
127
+ "CatalogEntry",
128
+ "ChatMessage",
129
+ "VerificationResult",
130
+ ]
@@ -0,0 +1,13 @@
1
+ from loomcast.adapters.cache.store import FileSystemCache
2
+ from loomcast.adapters.catalog.dynamic import DynamicCatalog
3
+ from loomcast.adapters.catalog.static import StaticCatalog
4
+ from loomcast.adapters.runtimes.lmstudio import LMStudioRuntime
5
+ from loomcast.adapters.runtimes.ollama import OllamaRuntime
6
+
7
+ __all__ = [
8
+ "OllamaRuntime",
9
+ "LMStudioRuntime",
10
+ "FileSystemCache",
11
+ "StaticCatalog",
12
+ "DynamicCatalog",
13
+ ]
@@ -0,0 +1,3 @@
1
+ from loomcast.adapters.cache.store import FileSystemCache
2
+
3
+ __all__ = ["FileSystemCache"]
@@ -0,0 +1,56 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+ from typing import Optional, Union
4
+
5
+ from loomcast.common.exceptions import CacheError
6
+ from loomcast.common.paths import get_cache_dir
7
+ from loomcast.ports.cache import CachePort
8
+
9
+
10
+ class FileSystemCache(CachePort):
11
+ """File system implementation of CachePort."""
12
+
13
+ def __init__(self, cache_dir: Optional[Union[str, Path]] = None):
14
+ if cache_dir is None:
15
+ self.cache_dir = get_cache_dir()
16
+ else:
17
+ self.cache_dir = Path(cache_dir)
18
+
19
+ try:
20
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
21
+ except OSError as e:
22
+ raise CacheError(f"Failed to create cache directory '{self.cache_dir}': {e}") from e
23
+
24
+ def _get_path(self, key: str) -> Path:
25
+ return self.cache_dir / key
26
+
27
+ def get(self, key: str) -> Optional[bytes]:
28
+ """Retrieve cached bytes by key, returning None if non-existent."""
29
+ path = self._get_path(key)
30
+ if not path.is_file():
31
+ return None
32
+ try:
33
+ return path.read_bytes()
34
+ except OSError as e:
35
+ raise CacheError(f"Failed to read cache entry '{key}': {e}") from e
36
+
37
+ def put(self, key: str, value: bytes) -> None:
38
+ """Store bytes into cache under key."""
39
+ path = self._get_path(key)
40
+ try:
41
+ path.write_bytes(value)
42
+ except OSError as e:
43
+ raise CacheError(f"Failed to write cache entry '{key}': {e}") from e
44
+
45
+ def exists(self, key: str) -> bool:
46
+ """Check if key exists in cache without reading contents."""
47
+ path = self._get_path(key)
48
+ try:
49
+ return path.is_file()
50
+ except OSError as e:
51
+ raise CacheError(f"Failed to check existence for cache entry '{key}': {e}") from e
52
+
53
+ @staticmethod
54
+ def compute_key(data: bytes) -> str:
55
+ """Compute hexadecimal SHA-256 hash of content."""
56
+ return hashlib.sha256(data).hexdigest()
@@ -0,0 +1,4 @@
1
+ from loomcast.adapters.catalog.dynamic import DynamicCatalog
2
+ from loomcast.adapters.catalog.static import StaticCatalog
3
+
4
+ __all__ = ["StaticCatalog", "DynamicCatalog"]
@@ -0,0 +1,154 @@
1
+ import json
2
+ import re
3
+ import time
4
+ from typing import List, Optional, Tuple
5
+
6
+ import httpx
7
+
8
+ from loomcast.adapters.cache.store import FileSystemCache
9
+ from loomcast.adapters.catalog.static import StaticCatalog
10
+ from loomcast.common.logging import logger
11
+ from loomcast.domain.entities import CatalogEntry, RuntimeType
12
+ from loomcast.ports.cache import CachePort
13
+ from loomcast.ports.catalog import CatalogPort
14
+
15
+ CACHE_SNAPSHOT_KEY = "catalog_dynamic_snapshot"
16
+
17
+
18
+ def _parse_library_html(html: str) -> List[CatalogEntry]:
19
+ """Parse HTML from ollama.com/library into a list of CatalogEntry items."""
20
+ entries: List[CatalogEntry] = []
21
+ seen_names = set()
22
+
23
+ # Pattern matches <a href="/library/<model_name>">...</a>
24
+ card_pattern = re.compile(
25
+ r'<a\s+[^>]*href=["\']/library/([a-zA-Z0-9._-]+)["\'][^>]*>(.*?)</a>',
26
+ re.DOTALL | re.IGNORECASE,
27
+ )
28
+ desc_pattern = re.compile(r'<p[^>]*>(.*?)</p>', re.DOTALL | re.IGNORECASE)
29
+ tag_pattern = re.compile(r'<span[^>]*>(.*?)</span>', re.DOTALL | re.IGNORECASE)
30
+ strip_tags_pattern = re.compile(r'<[^>]+>')
31
+
32
+ for match in card_pattern.finditer(html):
33
+ name = match.group(1).lower()
34
+ if name in seen_names:
35
+ continue
36
+ seen_names.add(name)
37
+
38
+ card_content = match.group(2)
39
+
40
+ # Extract description from <p>
41
+ desc_match = desc_pattern.search(card_content)
42
+ description = None
43
+ if desc_match:
44
+ clean_desc = strip_tags_pattern.sub("", desc_match.group(1)).strip()
45
+ if clean_desc:
46
+ description = clean_desc
47
+
48
+ # Extract tags from <span>
49
+ tags = []
50
+ for t_match in tag_pattern.finditer(card_content):
51
+ clean_tag = strip_tags_pattern.sub("", t_match.group(1)).strip()
52
+ if clean_tag and len(clean_tag) < 30:
53
+ tags.append(clean_tag)
54
+
55
+ if not tags:
56
+ tags = ["ollama"]
57
+
58
+ entries.append(
59
+ CatalogEntry(
60
+ name=name,
61
+ description=description,
62
+ publisher="ollama",
63
+ tags=tags,
64
+ default_runtime=RuntimeType.OLLAMA,
65
+ )
66
+ )
67
+
68
+ return entries
69
+
70
+
71
+ class DynamicCatalog(CatalogPort):
72
+ """Dynamic CatalogPort implementation with TTL caching and static fallback."""
73
+
74
+ def __init__(
75
+ self,
76
+ cache: Optional[CachePort] = None,
77
+ fallback_catalog: Optional[CatalogPort] = None,
78
+ ttl_hours: int = 24,
79
+ ):
80
+ self.cache: CachePort = cache if cache is not None else FileSystemCache()
81
+ self.fallback_catalog: CatalogPort = (
82
+ fallback_catalog if fallback_catalog is not None else StaticCatalog()
83
+ )
84
+ self.ttl_hours = ttl_hours
85
+
86
+ def _get_entries(self) -> Tuple[Optional[List[CatalogEntry]], bool]:
87
+ """Fetch entries from cache or network, or indicate fallback required."""
88
+ # 1. Try cache
89
+ raw_cache = self.cache.get(CACHE_SNAPSHOT_KEY)
90
+ if raw_cache:
91
+ try:
92
+ data = json.loads(raw_cache.decode("utf-8"))
93
+ timestamp = data.get("timestamp", 0)
94
+ if (time.time() - timestamp) < (self.ttl_hours * 3600):
95
+ entries_data = data.get("entries", [])
96
+ entries = [CatalogEntry.model_validate(item) for item in entries_data]
97
+ if entries:
98
+ return entries, False
99
+ except Exception as e:
100
+ logger.warning(f"Failed to read dynamic catalog cache snapshot: {e}")
101
+
102
+ # 2. Try scraping Ollama library
103
+ try:
104
+ response = httpx.get("https://ollama.com/library", timeout=10.0)
105
+ if response.status_code != 200:
106
+ raise ValueError(f"HTTP error status code {response.status_code}")
107
+ entries = _parse_library_html(response.text)
108
+
109
+ if not entries:
110
+ raise ValueError("No catalog entries parsed from HTML response")
111
+
112
+ # Save snapshot to cache
113
+ cache_payload = {
114
+ "timestamp": time.time(),
115
+ "entries": [e.model_dump() for e in entries],
116
+ }
117
+ self.cache.put(CACHE_SNAPSHOT_KEY, json.dumps(cache_payload).encode("utf-8"))
118
+ return entries, False
119
+ except Exception as e:
120
+ logger.warning(
121
+ f"Failed to fetch dynamic catalog from Ollama library ({e}). "
122
+ "Falling back to static catalog."
123
+ )
124
+ return None, True
125
+
126
+ def search(self, query: str) -> List[CatalogEntry]:
127
+ """Search catalog for entries matching query, falling back to static catalog if needed."""
128
+ entries, is_fallback = self._get_entries()
129
+ if is_fallback or entries is None:
130
+ return self.fallback_catalog.search(query)
131
+
132
+ query_lower = query.lower()
133
+ results: List[CatalogEntry] = []
134
+ for entry in entries:
135
+ name_match = query_lower in entry.name.lower()
136
+ desc_match = bool(entry.description and query_lower in entry.description.lower())
137
+ tag_match = any(query_lower in tag.lower() for tag in entry.tags)
138
+
139
+ if name_match or desc_match or tag_match:
140
+ results.append(entry)
141
+
142
+ return results
143
+
144
+ def get_entry(self, name: str) -> Optional[CatalogEntry]:
145
+ """Retrieve catalog entry by name, falling back to static catalog if needed."""
146
+ entries, is_fallback = self._get_entries()
147
+ if is_fallback or entries is None:
148
+ return self.fallback_catalog.get_entry(name)
149
+
150
+ name_lower = name.lower()
151
+ for entry in entries:
152
+ if entry.name.lower() == name_lower:
153
+ return entry
154
+ return None
@@ -0,0 +1,68 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import List, Optional, Union
4
+ from pydantic import ValidationError
5
+
6
+ from loomcast.common.exceptions import LoomcastError
7
+ from loomcast.domain.entities import CatalogEntry
8
+ from loomcast.ports.catalog import CatalogPort
9
+
10
+
11
+ class StaticCatalog(CatalogPort):
12
+ """CatalogPort implementation loading entries from an embedded JSON file."""
13
+
14
+ def __init__(self, catalog_path: Optional[Union[str, Path]] = None):
15
+ if catalog_path is None:
16
+ self.catalog_path = Path(__file__).parent / "data" / "catalog.json"
17
+ else:
18
+ self.catalog_path = Path(catalog_path)
19
+
20
+ self._entries: List[CatalogEntry] = self._load_catalog()
21
+
22
+ def _load_catalog(self) -> List[CatalogEntry]:
23
+ if not self.catalog_path.is_file():
24
+ raise LoomcastError(f"Catalog file not found at '{self.catalog_path}'.")
25
+
26
+ try:
27
+ with open(self.catalog_path, "r", encoding="utf-8") as f:
28
+ data = json.load(f)
29
+ except Exception as e:
30
+ raise LoomcastError(f"Failed to read catalog JSON file '{self.catalog_path}': {e}") from e
31
+
32
+ if not isinstance(data, list):
33
+ raise LoomcastError(f"Catalog JSON file '{self.catalog_path}' must contain a list of entries.")
34
+
35
+ entries: List[CatalogEntry] = []
36
+ for idx, item in enumerate(data):
37
+ try:
38
+ entry = CatalogEntry.model_validate(item)
39
+ entries.append(entry)
40
+ except ValidationError as ve:
41
+ raise LoomcastError(
42
+ f"Invalid CatalogEntry format at index {idx} in '{self.catalog_path}': {ve}"
43
+ ) from ve
44
+
45
+ return entries
46
+
47
+ def search(self, query: str) -> List[CatalogEntry]:
48
+ """Search catalog entries matching query in name, description, or tags (case-insensitive)."""
49
+ query_lower = query.lower()
50
+ results: List[CatalogEntry] = []
51
+
52
+ for entry in self._entries:
53
+ name_match = query_lower in entry.name.lower()
54
+ desc_match = bool(entry.description and query_lower in entry.description.lower())
55
+ tag_match = any(query_lower in tag.lower() for tag in entry.tags)
56
+
57
+ if name_match or desc_match or tag_match:
58
+ results.append(entry)
59
+
60
+ return results
61
+
62
+ def get_entry(self, name: str) -> Optional[CatalogEntry]:
63
+ """Retrieve exact catalog entry by name (case-insensitive comparison)."""
64
+ name_lower = name.lower()
65
+ for entry in self._entries:
66
+ if entry.name.lower() == name_lower:
67
+ return entry
68
+ return None
@@ -0,0 +1,13 @@
1
+ from loomcast.adapters.downloader.progress import (
2
+ NullProgressReporter,
3
+ ProgressReporter,
4
+ RichProgressReporter,
5
+ SubProgressReporter,
6
+ )
7
+
8
+ __all__ = [
9
+ "ProgressReporter",
10
+ "RichProgressReporter",
11
+ "NullProgressReporter",
12
+ "SubProgressReporter",
13
+ ]