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.
- loomcast-0.1.0/LICENSE +20 -0
- loomcast-0.1.0/PKG-INFO +112 -0
- loomcast-0.1.0/README.md +79 -0
- loomcast-0.1.0/loomcast/__init__.py +130 -0
- loomcast-0.1.0/loomcast/adapters/__init__.py +13 -0
- loomcast-0.1.0/loomcast/adapters/cache/__init__.py +3 -0
- loomcast-0.1.0/loomcast/adapters/cache/store.py +56 -0
- loomcast-0.1.0/loomcast/adapters/catalog/__init__.py +4 -0
- loomcast-0.1.0/loomcast/adapters/catalog/dynamic.py +154 -0
- loomcast-0.1.0/loomcast/adapters/catalog/static.py +68 -0
- loomcast-0.1.0/loomcast/adapters/downloader/__init__.py +13 -0
- loomcast-0.1.0/loomcast/adapters/downloader/progress.py +112 -0
- loomcast-0.1.0/loomcast/adapters/runtimes/__init__.py +9 -0
- loomcast-0.1.0/loomcast/adapters/runtimes/_openai_compat.py +33 -0
- loomcast-0.1.0/loomcast/adapters/runtimes/llamacpp.py +73 -0
- loomcast-0.1.0/loomcast/adapters/runtimes/lmstudio.py +71 -0
- loomcast-0.1.0/loomcast/adapters/runtimes/ollama.py +117 -0
- loomcast-0.1.0/loomcast/cli/__init__.py +3 -0
- loomcast-0.1.0/loomcast/cli/main.py +276 -0
- loomcast-0.1.0/loomcast/common/__init__.py +30 -0
- loomcast-0.1.0/loomcast/common/config.py +93 -0
- loomcast-0.1.0/loomcast/common/exceptions.py +35 -0
- loomcast-0.1.0/loomcast/common/logging.py +21 -0
- loomcast-0.1.0/loomcast/common/paths.py +31 -0
- loomcast-0.1.0/loomcast/core/__init__.py +5 -0
- loomcast-0.1.0/loomcast/core/manager.py +166 -0
- loomcast-0.1.0/loomcast/core/verifier.py +68 -0
- loomcast-0.1.0/loomcast/domain/__init__.py +8 -0
- loomcast-0.1.0/loomcast/domain/entities.py +45 -0
- loomcast-0.1.0/loomcast/ports/__init__.py +9 -0
- loomcast-0.1.0/loomcast/ports/cache.py +24 -0
- loomcast-0.1.0/loomcast/ports/catalog.py +21 -0
- loomcast-0.1.0/loomcast/ports/runtime.py +32 -0
- loomcast-0.1.0/loomcast.egg-info/PKG-INFO +112 -0
- loomcast-0.1.0/loomcast.egg-info/SOURCES.txt +51 -0
- loomcast-0.1.0/loomcast.egg-info/dependency_links.txt +1 -0
- loomcast-0.1.0/loomcast.egg-info/entry_points.txt +2 -0
- loomcast-0.1.0/loomcast.egg-info/requires.txt +16 -0
- loomcast-0.1.0/loomcast.egg-info/top_level.txt +1 -0
- loomcast-0.1.0/pyproject.toml +60 -0
- loomcast-0.1.0/setup.cfg +4 -0
- loomcast-0.1.0/tests/conftest.py +21 -0
- loomcast-0.1.0/tests/test_adapters.py +169 -0
- loomcast-0.1.0/tests/test_cache.py +77 -0
- loomcast-0.1.0/tests/test_catalog.py +68 -0
- loomcast-0.1.0/tests/test_catalog_dynamic.py +141 -0
- loomcast-0.1.0/tests/test_cli.py +122 -0
- loomcast-0.1.0/tests/test_config.py +117 -0
- loomcast-0.1.0/tests/test_domain.py +43 -0
- loomcast-0.1.0/tests/test_downloader.py +98 -0
- loomcast-0.1.0/tests/test_manager.py +212 -0
- loomcast-0.1.0/tests/test_ports.py +51 -0
- 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.
|
loomcast-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/<owner>/<repo>/actions/workflows/ci.yml)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](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
|
+
|
loomcast-0.1.0/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Loomcast
|
|
2
|
+
|
|
3
|
+
[](https://github.com/<owner>/<repo>/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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,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,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
|
+
]
|