cattykit 0.1.0__py3-none-any.whl
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.
- cattykit/__init__.py +10 -0
- cattykit/experiments.py +99 -0
- cattykit/logging/__init__.py +9 -0
- cattykit/logging/event.py +32 -0
- cattykit/logging/model_logger.py +18 -0
- cattykit/logging/null_logger.py +13 -0
- cattykit/logging/print_logger.py +24 -0
- cattykit/logging/sqlite_logger.py +761 -0
- cattykit/models/__init__.py +37 -0
- cattykit/models/loading.py +186 -0
- cattykit/models/protocol.py +59 -0
- cattykit/models/registry.py +157 -0
- cattykit/py.typed +1 -0
- cattykit-0.1.0.dist-info/METADATA +729 -0
- cattykit-0.1.0.dist-info/RECORD +18 -0
- cattykit-0.1.0.dist-info/WHEEL +5 -0
- cattykit-0.1.0.dist-info/licenses/LICENSE +661 -0
- cattykit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .loading import (
|
|
2
|
+
ModelInstallationError,
|
|
3
|
+
ModelNotInstalledError,
|
|
4
|
+
ModelSourceError,
|
|
5
|
+
available_models,
|
|
6
|
+
install_model,
|
|
7
|
+
load_model,
|
|
8
|
+
model_info,
|
|
9
|
+
resolve_model_source,
|
|
10
|
+
)
|
|
11
|
+
from .protocol import CattyKitModel, ModelFactory, ModelPlugin
|
|
12
|
+
from .registry import (
|
|
13
|
+
DuplicateModelError,
|
|
14
|
+
InvalidModelPluginError,
|
|
15
|
+
ModelRegistry,
|
|
16
|
+
ModelRegistryError,
|
|
17
|
+
default_registry,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CattyKitModel",
|
|
22
|
+
"DuplicateModelError",
|
|
23
|
+
"InvalidModelPluginError",
|
|
24
|
+
"ModelFactory",
|
|
25
|
+
"ModelInstallationError",
|
|
26
|
+
"ModelNotInstalledError",
|
|
27
|
+
"ModelSourceError",
|
|
28
|
+
"ModelPlugin",
|
|
29
|
+
"ModelRegistry",
|
|
30
|
+
"ModelRegistryError",
|
|
31
|
+
"available_models",
|
|
32
|
+
"default_registry",
|
|
33
|
+
"install_model",
|
|
34
|
+
"load_model",
|
|
35
|
+
"model_info",
|
|
36
|
+
"resolve_model_source",
|
|
37
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from importlib.util import find_spec
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
from urllib.request import url2pathname
|
|
11
|
+
|
|
12
|
+
from .protocol import CattyKitModel, ModelPlugin
|
|
13
|
+
from .registry import ModelRegistry, default_registry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModelNotInstalledError(LookupError):
|
|
17
|
+
"""Raised when a requested model plugin is not installed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModelSourceError(ValueError):
|
|
21
|
+
"""Raised when a model source cannot be interpreted."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ModelInstallationError(RuntimeError):
|
|
25
|
+
"""Raised when pip cannot install a model package."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ModelSourceKind = Literal["name", "url", "path"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
OFFICIAL_MODEL_SOURCES: dict[str, str] = {
|
|
32
|
+
"copycat": (
|
|
33
|
+
"git+https://github.com/georgeawright/resurrected-copycat.git"
|
|
34
|
+
"#subdirectory=models/copycat"
|
|
35
|
+
),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_model_source(
|
|
40
|
+
source: str | Path,
|
|
41
|
+
*,
|
|
42
|
+
model_sources: Mapping[str, str] = OFFICIAL_MODEL_SOURCES,
|
|
43
|
+
) -> tuple[ModelSourceKind, str]:
|
|
44
|
+
"""Classify a model source and resolve a model name to its package source.
|
|
45
|
+
|
|
46
|
+
``https://`` values are package URLs, while ``Path`` objects, ``file://``
|
|
47
|
+
URLs, and explicit paths (for example ``./copycat``) are local sources.
|
|
48
|
+
All other strings are treated as model names and looked up in
|
|
49
|
+
``model_sources``.
|
|
50
|
+
"""
|
|
51
|
+
if isinstance(source, Path):
|
|
52
|
+
return "path", str(source.expanduser())
|
|
53
|
+
|
|
54
|
+
parsed = urlparse(source)
|
|
55
|
+
|
|
56
|
+
if parsed.scheme == "https" and parsed.netloc:
|
|
57
|
+
return "url", source
|
|
58
|
+
|
|
59
|
+
if parsed.scheme == "file":
|
|
60
|
+
if parsed.netloc not in ("", "localhost"):
|
|
61
|
+
raise ModelSourceError("Only local file URLs are supported.")
|
|
62
|
+
return "path", url2pathname(unquote(parsed.path))
|
|
63
|
+
|
|
64
|
+
if _is_explicit_path(source):
|
|
65
|
+
return "path", str(Path(source).expanduser())
|
|
66
|
+
|
|
67
|
+
if parsed.scheme:
|
|
68
|
+
raise ModelSourceError(
|
|
69
|
+
"Model URLs must use HTTPS; use a local path or file:// URL "
|
|
70
|
+
"for local packages."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return "name", model_sources[source]
|
|
75
|
+
except KeyError as error:
|
|
76
|
+
raise ModelSourceError(
|
|
77
|
+
f"No CattyKit model source is registered as {source!r}. "
|
|
78
|
+
"Pass an HTTPS URL or an explicit local path instead."
|
|
79
|
+
) from error
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def install_model(
|
|
83
|
+
source: str | Path,
|
|
84
|
+
*,
|
|
85
|
+
model_sources: Mapping[str, str] = OFFICIAL_MODEL_SOURCES,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Install a CattyKit model package into the current Python environment.
|
|
88
|
+
|
|
89
|
+
A name is resolved through the curated model-source map. HTTPS URLs and
|
|
90
|
+
explicit local paths are passed directly to pip.
|
|
91
|
+
"""
|
|
92
|
+
_, package_source = resolve_model_source(source, model_sources=model_sources)
|
|
93
|
+
|
|
94
|
+
if find_spec("pip") is None:
|
|
95
|
+
raise ModelInstallationError(
|
|
96
|
+
"The active Python environment does not include pip. "
|
|
97
|
+
"Install the model with your environment manager instead, for example: "
|
|
98
|
+
f"uv pip install {package_source}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
subprocess.run(
|
|
103
|
+
[sys.executable, "-m", "pip", "install", package_source],
|
|
104
|
+
check=True,
|
|
105
|
+
)
|
|
106
|
+
except subprocess.CalledProcessError as error:
|
|
107
|
+
raise ModelInstallationError(
|
|
108
|
+
f"Could not install CattyKit model from {package_source!r}."
|
|
109
|
+
) from error
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def available_models(
|
|
113
|
+
*,
|
|
114
|
+
registry: ModelRegistry = default_registry,
|
|
115
|
+
) -> tuple[str, ...]:
|
|
116
|
+
"""Return the names of all installed CattyKit models."""
|
|
117
|
+
return registry.names()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def model_info(
|
|
121
|
+
name: str,
|
|
122
|
+
*,
|
|
123
|
+
registry: ModelRegistry = default_registry,
|
|
124
|
+
) -> ModelPlugin:
|
|
125
|
+
"""Return metadata and the factory for an installed model."""
|
|
126
|
+
try:
|
|
127
|
+
return registry.get(name)
|
|
128
|
+
except KeyError as error:
|
|
129
|
+
raise _model_not_installed_error(
|
|
130
|
+
name,
|
|
131
|
+
installed=registry.names(),
|
|
132
|
+
) from error
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_model(
|
|
136
|
+
name: str,
|
|
137
|
+
*,
|
|
138
|
+
config: Mapping[str, Any] | None = None,
|
|
139
|
+
registry: ModelRegistry = default_registry,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> CattyKitModel:
|
|
142
|
+
"""Construct an installed CattyKit model.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
name:
|
|
147
|
+
Registered model name, such as ``"copycat"``.
|
|
148
|
+
config:
|
|
149
|
+
Optional model-specific configuration.
|
|
150
|
+
registry:
|
|
151
|
+
Registry to use. Primarily useful for testing.
|
|
152
|
+
**kwargs:
|
|
153
|
+
Additional model-specific factory arguments.
|
|
154
|
+
"""
|
|
155
|
+
plugin = model_info(name, registry=registry)
|
|
156
|
+
return plugin.create(config=config, **kwargs)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _model_not_installed_error(
|
|
160
|
+
name: str,
|
|
161
|
+
*,
|
|
162
|
+
installed: tuple[str, ...],
|
|
163
|
+
) -> ModelNotInstalledError:
|
|
164
|
+
installed_text = ", ".join(installed) if installed else "none"
|
|
165
|
+
|
|
166
|
+
lines = [
|
|
167
|
+
f"CattyKit model {name!r} is not installed.",
|
|
168
|
+
f"Installed models: {installed_text}.",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
source = OFFICIAL_MODEL_SOURCES.get(name)
|
|
172
|
+
|
|
173
|
+
if source is not None:
|
|
174
|
+
lines.extend(
|
|
175
|
+
[
|
|
176
|
+
"",
|
|
177
|
+
"Install the official model with:",
|
|
178
|
+
f" python -c \"import cattykit; cattykit.install_model('{name}')\"",
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return ModelNotInstalledError("\n".join(lines))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _is_explicit_path(value: str) -> bool:
|
|
186
|
+
return value.startswith(("./", "../", "~/", "/")) or "/" in value
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class CattyKitModel(Protocol):
|
|
10
|
+
"""Minimum interface implemented by a runnable CattyKit model."""
|
|
11
|
+
|
|
12
|
+
def solve(self, *args: Any, **kwargs: Any) -> Any:
|
|
13
|
+
"""Run the model and return its result."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def close(self) -> None:
|
|
17
|
+
"""Release resources owned by the model."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class ModelFactory(Protocol):
|
|
23
|
+
"""Callable used to construct a model plugin."""
|
|
24
|
+
|
|
25
|
+
def __call__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
config: Mapping[str, Any] | None = None,
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
) -> CattyKitModel:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ModelPlugin:
|
|
36
|
+
"""Description of an installed CattyKit model plugin."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
factory: ModelFactory
|
|
40
|
+
version: str | None = None
|
|
41
|
+
description: str | None = None
|
|
42
|
+
|
|
43
|
+
def create(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
config: Mapping[str, Any] | None = None,
|
|
47
|
+
**kwargs: Any,
|
|
48
|
+
) -> CattyKitModel:
|
|
49
|
+
"""Construct a model using this plugin's factory."""
|
|
50
|
+
model = self.factory(config=config, **kwargs)
|
|
51
|
+
|
|
52
|
+
if not isinstance(model, CattyKitModel):
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"Factory for model {self.name!r} returned "
|
|
55
|
+
f"{type(model).__name__}, which does not satisfy "
|
|
56
|
+
"the CattyKitModel protocol."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return model
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from .protocol import ModelPlugin
|
|
8
|
+
|
|
9
|
+
ENTRY_POINT_GROUP: Final = "cattykit.models"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelRegistryError(RuntimeError):
|
|
13
|
+
"""Base exception for model-registry failures."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DuplicateModelError(ModelRegistryError):
|
|
17
|
+
"""Raised when more than one plugin uses the same model name."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidModelPluginError(ModelRegistryError):
|
|
21
|
+
"""Raised when an entry point does not provide a valid plugin."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ModelRegistry:
|
|
25
|
+
"""Registry of installed and explicitly registered CattyKit models."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._plugins: dict[str, ModelPlugin] = {}
|
|
29
|
+
self._discovered = False
|
|
30
|
+
|
|
31
|
+
def register(
|
|
32
|
+
self,
|
|
33
|
+
plugin: ModelPlugin,
|
|
34
|
+
*,
|
|
35
|
+
replace: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Register a plugin directly.
|
|
38
|
+
|
|
39
|
+
Direct registration is useful for tests and locally defined models.
|
|
40
|
+
"""
|
|
41
|
+
self._validate_name(plugin.name)
|
|
42
|
+
|
|
43
|
+
if plugin.name in self._plugins and not replace:
|
|
44
|
+
raise DuplicateModelError(
|
|
45
|
+
f"A CattyKit model named {plugin.name!r} is already registered."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self._plugins[plugin.name] = plugin
|
|
49
|
+
|
|
50
|
+
def discover(self, *, force: bool = False) -> None:
|
|
51
|
+
"""Discover plugins registered through Python entry points."""
|
|
52
|
+
if self._discovered and not force:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if force:
|
|
56
|
+
self._plugins.clear()
|
|
57
|
+
|
|
58
|
+
discovered = entry_points(group=ENTRY_POINT_GROUP)
|
|
59
|
+
|
|
60
|
+
for entry_point in discovered:
|
|
61
|
+
plugin = self._load_entry_point(entry_point)
|
|
62
|
+
self.register(plugin)
|
|
63
|
+
|
|
64
|
+
self._discovered = True
|
|
65
|
+
|
|
66
|
+
def get(self, name: str) -> ModelPlugin:
|
|
67
|
+
"""Return a registered model plugin."""
|
|
68
|
+
self.discover()
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return self._plugins[name]
|
|
72
|
+
except KeyError as error:
|
|
73
|
+
raise KeyError(
|
|
74
|
+
f"No installed CattyKit model is registered as {name!r}."
|
|
75
|
+
) from error
|
|
76
|
+
|
|
77
|
+
def contains(self, name: str) -> bool:
|
|
78
|
+
"""Return whether a model is registered."""
|
|
79
|
+
self.discover()
|
|
80
|
+
return name in self._plugins
|
|
81
|
+
|
|
82
|
+
def names(self) -> tuple[str, ...]:
|
|
83
|
+
"""Return installed model names in sorted order."""
|
|
84
|
+
self.discover()
|
|
85
|
+
return tuple(sorted(self._plugins))
|
|
86
|
+
|
|
87
|
+
def plugins(self) -> tuple[ModelPlugin, ...]:
|
|
88
|
+
"""Return installed plugins sorted by name."""
|
|
89
|
+
self.discover()
|
|
90
|
+
return tuple(self._plugins[name] for name in sorted(self._plugins))
|
|
91
|
+
|
|
92
|
+
def register_many(
|
|
93
|
+
self,
|
|
94
|
+
plugins: Iterable[ModelPlugin],
|
|
95
|
+
*,
|
|
96
|
+
replace: bool = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
for plugin in plugins:
|
|
99
|
+
self.register(plugin, replace=replace)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _load_entry_point(entry_point: EntryPoint) -> ModelPlugin:
|
|
103
|
+
try:
|
|
104
|
+
loaded_object = entry_point.load()
|
|
105
|
+
except Exception as error:
|
|
106
|
+
raise InvalidModelPluginError(
|
|
107
|
+
f"Could not load model entry point "
|
|
108
|
+
f"{entry_point.name!r} from {entry_point.value!r}."
|
|
109
|
+
) from error
|
|
110
|
+
|
|
111
|
+
# Permit the entry point to expose either:
|
|
112
|
+
#
|
|
113
|
+
# 1. a ModelPlugin instance; or
|
|
114
|
+
# 2. a zero-argument function returning a ModelPlugin.
|
|
115
|
+
if isinstance(loaded_object, ModelPlugin):
|
|
116
|
+
plugin = loaded_object
|
|
117
|
+
elif callable(loaded_object):
|
|
118
|
+
try:
|
|
119
|
+
plugin = loaded_object()
|
|
120
|
+
except Exception as error:
|
|
121
|
+
raise InvalidModelPluginError(
|
|
122
|
+
f"Model entry point {entry_point.name!r} raised an "
|
|
123
|
+
"exception while creating its plugin descriptor."
|
|
124
|
+
) from error
|
|
125
|
+
else:
|
|
126
|
+
raise InvalidModelPluginError(
|
|
127
|
+
f"Model entry point {entry_point.name!r} must expose a "
|
|
128
|
+
"ModelPlugin or a callable returning one."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not isinstance(plugin, ModelPlugin):
|
|
132
|
+
raise InvalidModelPluginError(
|
|
133
|
+
f"Model entry point {entry_point.name!r} returned "
|
|
134
|
+
f"{type(plugin).__name__}, not ModelPlugin."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if plugin.name != entry_point.name:
|
|
138
|
+
raise InvalidModelPluginError(
|
|
139
|
+
f"Entry point name {entry_point.name!r} does not match "
|
|
140
|
+
f"plugin name {plugin.name!r}."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return plugin
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _validate_name(name: str) -> None:
|
|
147
|
+
if not name:
|
|
148
|
+
raise ValueError("A model name cannot be empty.")
|
|
149
|
+
|
|
150
|
+
if not name.replace("-", "_").isalnum():
|
|
151
|
+
raise ValueError(
|
|
152
|
+
"Model names may contain letters, digits, underscores "
|
|
153
|
+
f"and hyphens; got {name!r}."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
default_registry = ModelRegistry()
|
cattykit/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# This package provides inline type information.
|