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.
@@ -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.