mreg-api 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.
mreg_api/__about__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Package metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mreg_api._version import __version__
6
+
7
+ __all__ = ["__version__"]
mreg_api/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """MREG API - Python client library for MREG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mreg_api import cache
6
+ from mreg_api import dirs
7
+ from mreg_api import endpoints
8
+ from mreg_api import exceptions
9
+ from mreg_api import models
10
+ from mreg_api import types
11
+ from mreg_api.__about__ import __version__
12
+ from mreg_api.cache import CacheConfig
13
+ from mreg_api.client import MregClient
14
+
15
+ __all__ = [
16
+ "__version__",
17
+ "MregClient",
18
+ "CacheConfig",
19
+ "cache",
20
+ "dirs",
21
+ "endpoints",
22
+ "exceptions",
23
+ "models",
24
+ "types",
25
+ ]
mreg_api/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
+
34
+ __commit_id__ = commit_id = None
mreg_api/cache.py ADDED
@@ -0,0 +1,205 @@
1
+ """Caching utilities for mreg-api."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Generic
7
+ from typing import ParamSpec
8
+ from typing import TypeVar
9
+ from typing import cast
10
+ from typing import final
11
+
12
+ from diskcache import Cache
13
+ from pydantic import BaseModel
14
+ from pydantic import ByteSize
15
+ from pydantic import field_serializer
16
+
17
+ from mreg_api.exceptions import CacheError
18
+ from mreg_api.exceptions import CacheMiss
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ P = ParamSpec("P")
24
+ T = TypeVar("T")
25
+
26
+ DEFAULT_CACHE_TAG = "mreg-api"
27
+ DEFAULT_CACHE_TTL = 300 # seconds
28
+
29
+ _CACHE_MISS = object()
30
+
31
+
32
+ class CacheInfo(BaseModel):
33
+ """Information about the cache."""
34
+
35
+ items: int
36
+ size: ByteSize
37
+ hits: int
38
+ misses: int
39
+ ttl: int
40
+ directory: str
41
+
42
+ @field_serializer("size")
43
+ def _serialize_size(self, value: ByteSize) -> str:
44
+ return value.human_readable()
45
+
46
+
47
+ def _create_cache(config: CacheConfig) -> Cache | None:
48
+ """Create a diskcache.Cache based on the provided configuration.
49
+
50
+ If the diskcache.Cache cannot be created (e.g., filesystem access denied),
51
+ returns None.
52
+
53
+ Args:
54
+ config: Cache configuration.
55
+
56
+ Returns:
57
+ diskcache.Cache instance, or None if creation failed.
58
+ """
59
+ if not config.enable:
60
+ logger.debug("Cache is disabled in config, not creating cache.")
61
+ return None
62
+
63
+ try:
64
+ return Cache(directory=config.directory, timeout=config.timeout)
65
+ except Exception as e:
66
+ logger.warning("Failed to create diskcache.Cache: %s. Cache will be disabled.", e)
67
+ return None
68
+
69
+
70
+ class CacheConfig(BaseModel):
71
+ """Configuration for the mreg-api cache."""
72
+
73
+ enable: bool = True
74
+ ttl: int = 300
75
+ tag: str = "mreg-api"
76
+ timeout: int = 60
77
+ directory: str | None = None
78
+
79
+
80
+ @final
81
+ class MregApiCache(Generic[T]):
82
+ """Wrapper around the mreg-api cache.
83
+
84
+ This class can operate in two modes:
85
+ 1. Enabled: When a valid diskcache.Cache is provided
86
+ 2. Disabled: When cache is None (no-op mode, all operations are safe to call)
87
+
88
+ The disabled mode allows callers to use the cache interface without null checks.
89
+ """
90
+
91
+ def __init__(self, cache: Cache | None, config: CacheConfig) -> None:
92
+ """Initialize the cache wrapper.
93
+
94
+ Args:
95
+ cache: The underlying diskcache.Cache instance, or None for disabled mode.
96
+ config: Configuration for cache behavior.
97
+ """
98
+ # NOTE: VERY IMPORTANT!!
99
+ # _NEVER_ do `if self._cache` checks, as diskcache.Cache implements __bool__
100
+ # to check if the cache is non-empty, which is NOT what we want anywhere.
101
+ self._cache = cache
102
+ self.config = config
103
+
104
+ @property
105
+ def has_backend(self) -> bool:
106
+ """Return True if an underlying cache backend is available."""
107
+ return self._cache is not None
108
+
109
+ @property
110
+ def is_enabled(self) -> bool:
111
+ """Return True if cache is operational (has backend and is enabled in config)."""
112
+ return self.has_backend and self.config.enable
113
+
114
+ @classmethod
115
+ def new(cls, config: CacheConfig) -> MregApiCache[T]:
116
+ """Create a new MregApiCache instance based on the provided configuration.
117
+
118
+ Args:
119
+ config: Cache configuration.
120
+
121
+ Returns:
122
+ MregApiCache instance (may be in disabled mode if creation failed).
123
+ """
124
+ cache = _create_cache(config)
125
+ return cls(cache, config=config)
126
+
127
+ def enable(self) -> None:
128
+ """Enable the cache."""
129
+ self.config.enable = True
130
+ # We have no cache backend yet, try to create one
131
+ if self._cache is None:
132
+ self._cache = _create_cache(self.config)
133
+
134
+ def disable(self) -> None:
135
+ """Disable the cache."""
136
+ self.config.enable = False
137
+
138
+ def get_info(self) -> CacheInfo | None:
139
+ """Get information about the cache.
140
+
141
+ Returns:
142
+ CacheInfo object with cache statistics, or None if cache is disabled.
143
+ """
144
+ # No cache object exists.
145
+ # We can return stats for disabled cache as long as we have a backend.
146
+ if self._cache is None:
147
+ return None
148
+
149
+ hits, misses = self._cache.stats()
150
+
151
+ return CacheInfo(
152
+ size=self._cache.volume(), # pyright: ignore[reportAny]
153
+ hits=hits, # pyright: ignore[reportArgumentType]
154
+ misses=misses, # pyright: ignore[reportArgumentType]
155
+ items=len(self._cache), # pyright: ignore[reportArgumentType]
156
+ directory=self._cache.directory,
157
+ ttl=self.config.ttl,
158
+ )
159
+
160
+ def set(self, key: str, value: T | None, expire: int | None = None) -> None:
161
+ """Set a value in the cache.
162
+
163
+ No-op if cache is disabled.
164
+ """
165
+ if not self.is_enabled or self._cache is None:
166
+ return
167
+ try:
168
+ self._cache.set(key, value, expire=expire or self.config.ttl, tag=self.config.tag)
169
+ except Exception as e:
170
+ raise CacheError(f"Failed to set cache key {key}: {e}") from e
171
+
172
+ def get(self, key: str) -> T | None:
173
+ """Get a value from the cache.
174
+
175
+ Raises:
176
+ CacheMiss: If the key is not found in the cache or cache is disabled.
177
+ """
178
+ if not self.is_enabled or self._cache is None:
179
+ raise CacheMiss(f"Cache disabled, key: {key}")
180
+
181
+ # Use sentinel to distinguish between None and missing key
182
+ try:
183
+ value = self._cache.get(key, default=_CACHE_MISS) # pyright: ignore[reportReturnType, reportUnknownVariableType]
184
+ except Exception as e:
185
+ raise CacheError(f"Failed to get cache key {key}: {e}") from e
186
+
187
+ if value is _CACHE_MISS:
188
+ raise CacheMiss(f"Cache miss for key: {key}")
189
+ # NOTE: diskcache.Cache.get has poor type annotations
190
+ # so we cannot properly type hint the return value here.
191
+ return cast(T | None, value)
192
+
193
+ def clear(self) -> int:
194
+ """Clear the cache and reset statistics.
195
+
196
+ Returns 0 if cache is disabled.
197
+ """
198
+ if not self.is_enabled or self._cache is None:
199
+ return 0
200
+ try:
201
+ n_items = self._cache.evict(self.config.tag)
202
+ logger.info("Cleared %d items from cache with tag %s", n_items, self.config.tag)
203
+ return n_items
204
+ except Exception as e:
205
+ raise CacheError(f"Failed to clear cache: {e}") from e