logged-cache 0.0.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.
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ <!-- version list -->
4
+
5
+ ## v0.0.0 (2026-05-23)
6
+
7
+ - Initial Release
8
+
9
+ ## 0.1.0
10
+
11
+ - Initial package structure.
12
+ - Added `LoggedCache`, `LRUCacheManager`, `TTLCacheManager`, stats, hooks, and
13
+ decorator helpers.
14
+ - Added tests, documentation, and GitHub Actions workflows.
@@ -0,0 +1,39 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ uv sync --extra dev --extra docs
7
+ ```
8
+
9
+ Without `uv`:
10
+
11
+ ```bash
12
+ python -m venv .venv
13
+ . .venv/bin/activate
14
+ python -m pip install -e ".[dev,docs]"
15
+ ```
16
+
17
+ ## Checks
18
+
19
+ ```bash
20
+ ruff check .
21
+ mypy
22
+ pytest
23
+ python -m build
24
+ twine check dist/*
25
+ ```
26
+
27
+ ## Releases
28
+
29
+ Releases are automated with Python Semantic Release and Conventional Commits.
30
+ Pushes to `main` are analyzed and a release is created only when commits require
31
+ one:
32
+
33
+ - `fix: ...` creates a patch release.
34
+ - `feat: ...` creates a minor release.
35
+ - `feat!: ...` or a `BREAKING CHANGE:` footer creates a major release.
36
+
37
+ Release commits are generated as `chore(release): <version> [skip ci]`.
38
+
39
+ Please keep changes focused and include tests for behavior changes.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 51n91n51nk1n
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 ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,389 @@
1
+ Metadata-Version: 2.4
2
+ Name: logged-cache
3
+ Version: 0.0.0
4
+ Summary: Small logged cachetools wrappers with cache managers, stats, and decorators.
5
+ Project-URL: Homepage, https://github.com/51n91n51nk1n/logged_cache
6
+ Project-URL: Documentation, https://github.com/51n91n51nk1n/logged_cache#readme
7
+ Project-URL: Issues, https://github.com/51n91n51nk1n/logged_cache/issues
8
+ Project-URL: Source, https://github.com/51n91n51nk1n/logged_cache
9
+ Author: 51n91n51nk1n
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cache,cachetools,logging,lru,ttl
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: cachetools<7,>=5.3
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.2; extra == 'dev'
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: packaging>=25; extra == 'dev'
28
+ Requires-Dist: pkginfo>=1.12; extra == 'dev'
29
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
30
+ Requires-Dist: pylint>=3.3; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: python-semantic-release>=10.5; extra == 'dev'
34
+ Requires-Dist: ruff>=0.5; extra == 'dev'
35
+ Requires-Dist: twine>=5.0; extra == 'dev'
36
+ Requires-Dist: types-cachetools>=6.2; extra == 'dev'
37
+ Provides-Extra: docs
38
+ Requires-Dist: mkdocs>=1.6; extra == 'docs'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # logged-cache
42
+
43
+ `logged-cache` is a small Python package around
44
+ [`cachetools`](https://cachetools.readthedocs.io/) for applications that want
45
+ cache hit/miss logs without rewriting their caching code.
46
+
47
+ It provides:
48
+
49
+ - `LoggedCache`, a mutable mapping wrapper that logs cache activity.
50
+ - `LRUCacheManager` and `TTLCacheManager`, with a shared lock for
51
+ `cachetools.cached`.
52
+ - `CacheStats`, lightweight counters for hits, misses, writes, deletes, and
53
+ clears.
54
+ - `cached` and `cachedmethod`, convenience decorators for functions and methods.
55
+ - Synchronous and asynchronous function caching with the same decorator.
56
+ - Hooks and key formatters for metrics, redaction, and structured logging.
57
+
58
+ ## Installation
59
+
60
+ ```bash
61
+ pip install logged-cache
62
+ ```
63
+
64
+ For local development with `uv`:
65
+
66
+ ```bash
67
+ uv sync --extra dev --extra docs
68
+ uv run pytest
69
+ ```
70
+
71
+ If you do not use `uv`, the project is still standard PEP 621 Python packaging:
72
+
73
+ ```bash
74
+ python -m venv .venv
75
+ . .venv/bin/activate
76
+ python -m pip install -e ".[dev,docs]"
77
+ pytest
78
+ ```
79
+
80
+ ## Quick Start
81
+
82
+ ```python
83
+ import logging
84
+
85
+ from logged_cache import (
86
+ LRUCacheManager,
87
+ TTLCacheManager,
88
+ cached,
89
+ cachedmethod,
90
+ )
91
+
92
+ logger = logging.getLogger("myapp.cache")
93
+
94
+ # Use LRU when you want a bounded cache.
95
+ users = LRUCacheManager(maxsize=256, logger=logger, name="users")
96
+
97
+
98
+ @cached(users)
99
+ def load_user(user_id: int) -> dict[str, int | str]:
100
+ return {"id": user_id, "name": "Ada"}
101
+
102
+
103
+ # Use TTL when values should expire.
104
+ tokens = TTLCacheManager(maxsize=1000, ttl=300, logger=logger, name="tokens")
105
+
106
+
107
+ @cached(tokens)
108
+ def fetch_token(account_id: str) -> str:
109
+ return f"token-for-{account_id}"
110
+
111
+
112
+ # Use cachedmethod for instance or class methods.
113
+ class ProfileService:
114
+ cache = LRUCacheManager(maxsize=256, logger=logger, name="profiles")
115
+
116
+ @cachedmethod(cache)
117
+ def load_profile(self, user_id: int) -> dict[str, int | str]:
118
+ return {"id": user_id, "name": "Ada"}
119
+
120
+
121
+ # The same decorators work with async functions and methods.
122
+ @cached(users)
123
+ async def load_user_async(user_id: int) -> dict[str, int | str]:
124
+ return {"id": user_id, "name": "Ada"}
125
+
126
+
127
+ load_user(1) # miss, then set.
128
+ load_user(1) # hit.
129
+ fetch_token("main") # TTL miss, then set.
130
+ ProfileService().load_profile(1) # method cache.
131
+
132
+ print(users.stats.hits, users.stats.misses, users.stats.hit_rate)
133
+ ```
134
+
135
+ With a standard logging formatter, cache events look like this:
136
+
137
+ ```text
138
+ DEBUG:myapp.cache:[users] Cache miss: function=load_user key=(1,)
139
+ DEBUG:myapp.cache:[users] Cache set: function=load_user key=(1,)
140
+ DEBUG:myapp.cache:[users] Cache hit: function=load_user key=(1,)
141
+ ```
142
+
143
+ If `logger` is not provided, `logging.getLogger()` is used. Each log record also
144
+ contains `cache_name`, `cache_function`, `cache_event`, and `cache_key`
145
+ attributes for structured logging. When a cache is used through `cached`,
146
+ `cachedmethod`, `async_cached`, or `async_cachedmethod`, log messages also show
147
+ the function or method name.
148
+
149
+ If `name` is omitted, decorators set a readable fallback name from the decorated
150
+ callable, such as `myapp.services.UserService.load_user`.
151
+
152
+ Keys are formatted defensively before they are written to logs. Very large keys
153
+ are truncated, and plain object instances are shown as readable class names
154
+ instead of memory-address-heavy default representations:
155
+
156
+ ```text
157
+ DEBUG:myapp.cache:[users] Cache miss: key=<myapp.queries.UserQuery>
158
+ DEBUG:myapp.cache:[users] Cache miss: key=('xxxxxxxxxxxxxxxxxxxx...<truncated>)
159
+ ```
160
+
161
+ ## LRU Cache
162
+
163
+ Use `LRUCacheManager` when you want a fixed-size cache where the least recently
164
+ used entries are evicted first.
165
+
166
+ ```python
167
+ import logging
168
+
169
+ from logged_cache import LRUCacheManager, cached
170
+
171
+ logger = logging.getLogger("myapp.cache")
172
+ users = LRUCacheManager(maxsize=256, logger=logger, name="users")
173
+
174
+
175
+ @cached(users)
176
+ def load_user(user_id: int) -> dict[str, int | str]:
177
+ return {"id": user_id, "name": "Ada"}
178
+
179
+
180
+ load_user(1)
181
+ load_user(1)
182
+
183
+ print(users.stats.hits, users.stats.misses, users.stats.hit_rate)
184
+ ```
185
+
186
+ ## TTL Cache
187
+
188
+ ```python
189
+ from logged_cache import TTLCacheManager, cached
190
+
191
+ tokens = TTLCacheManager(maxsize=1000, ttl=300)
192
+
193
+
194
+ @cached(tokens)
195
+ def fetch_token(account_id: str) -> str:
196
+ return f"token-for-{account_id}"
197
+ ```
198
+
199
+ ## Async Functions
200
+
201
+ Use the same `cached` decorator for `async def`. The awaited result is cached,
202
+ not the coroutine object.
203
+
204
+ ```python
205
+ from logged_cache import LRUCacheManager, cached
206
+
207
+ profiles = LRUCacheManager(maxsize=256)
208
+
209
+
210
+ @cached(profiles)
211
+ async def fetch_profile(user_id: int) -> dict[str, int | str]:
212
+ return {"id": user_id, "name": "Ada"}
213
+
214
+
215
+ profile = await fetch_profile(1)
216
+ ```
217
+
218
+ For async-only applications, use `AsyncLRUCacheManager` or
219
+ `AsyncTTLCacheManager` with `async_cached` to protect cache operations with
220
+ `asyncio.Lock`.
221
+
222
+ ```python
223
+ from logged_cache import AsyncLRUCacheManager, async_cached
224
+
225
+ profiles = AsyncLRUCacheManager(maxsize=256, name="profiles")
226
+
227
+
228
+ @async_cached(profiles)
229
+ async def fetch_profile(user_id: int) -> dict[str, int | str]:
230
+ return {"id": user_id, "name": "Ada"}
231
+ ```
232
+
233
+ ## Methods
234
+
235
+ Use `cachedmethod` for instance methods and class methods. By default, it uses
236
+ `cachetools.keys.methodkey`, so the first method argument (`self` or `cls`) is
237
+ not included in the cache key.
238
+
239
+ ```python
240
+ from logged_cache import LRUCacheManager, cachedmethod
241
+
242
+
243
+ class ProfileService:
244
+ cache = LRUCacheManager(maxsize=256)
245
+
246
+ @cachedmethod(cache)
247
+ def load_profile(self, user_id: int) -> dict[str, int | str]:
248
+ return {"id": user_id, "name": "Ada"}
249
+ ```
250
+
251
+ Async methods work the same way:
252
+
253
+ ```python
254
+ from logged_cache import AsyncLRUCacheManager, async_cachedmethod
255
+
256
+
257
+ class AsyncProfileService:
258
+ cache = AsyncLRUCacheManager(maxsize=256)
259
+
260
+ @async_cachedmethod(cache)
261
+ async def load_profile(self, user_id: int) -> dict[str, int | str]:
262
+ return {"id": user_id, "name": "Ada"}
263
+ ```
264
+
265
+ ## Direct Mapping Usage
266
+
267
+ ```python
268
+ from cachetools import LRUCache
269
+ from logged_cache import LoggedCache
270
+
271
+ cache = LoggedCache(LRUCache(maxsize=2))
272
+ cache["a"] = 1
273
+
274
+ if "a" in cache:
275
+ print(cache["a"])
276
+ ```
277
+
278
+ Direct mapping operations emit the same event names:
279
+
280
+ ```text
281
+ DEBUG:myapp.cache:[logged-cache] Cache set: key='a'
282
+ DEBUG:myapp.cache:[logged-cache] Cache hit: key='a'
283
+ DEBUG:myapp.cache:[logged-cache] Cache delete: key='a'
284
+ DEBUG:myapp.cache:[logged-cache] Cache cleared
285
+ ```
286
+
287
+ ## Redacting Sensitive Keys
288
+
289
+ By default, keys are logged with a safe formatter that truncates large values
290
+ and describes plain object instances by class. For user IDs, tokens, emails, or
291
+ other sensitive values, pass a formatter:
292
+
293
+ ```python
294
+ from logged_cache import LRUCacheManager
295
+
296
+
297
+ def redact_key(key: object) -> str:
298
+ return "<redacted>"
299
+
300
+
301
+ cache = LRUCacheManager(maxsize=128, key_formatter=redact_key)
302
+ ```
303
+
304
+ To keep the default behavior but change the maximum key length, use
305
+ `format_cache_key`:
306
+
307
+ ```python
308
+ from functools import partial
309
+ from logged_cache import LRUCacheManager, format_cache_key
310
+
311
+ cache = LRUCacheManager(
312
+ maxsize=128,
313
+ key_formatter=partial(format_cache_key, max_length=80),
314
+ )
315
+ ```
316
+
317
+ ## Naming Caches
318
+
319
+ Pass `name` when the same logger receives events from several caches.
320
+
321
+ ```python
322
+ users = LRUCacheManager(maxsize=256, name="users")
323
+ tokens = TTLCacheManager(maxsize=1000, ttl=300, name="tokens")
324
+ ```
325
+
326
+ If `name` is omitted and the manager is used with `cached` or `cachedmethod`,
327
+ the package sets a fallback from the decorated callable's module and qualified
328
+ name. Explicit names are never replaced by decorators.
329
+
330
+ ## Exporting Metrics
331
+
332
+ Use `on_event` to bridge cache events into your metrics stack.
333
+
334
+ ```python
335
+ from logged_cache import CacheEvent, LRUCacheManager
336
+
337
+
338
+ def record_metric(event: CacheEvent, key: object) -> None:
339
+ print(f"cache.{event}")
340
+
341
+
342
+ cache = LRUCacheManager(maxsize=128, on_event=record_metric)
343
+ ```
344
+
345
+ ## API Overview
346
+
347
+ `LoggedCache(inner, logger=None, log_level=logging.DEBUG, stats=None,
348
+ key_formatter=repr, on_event=None, name=None)` wraps any mutable mapping, including
349
+ `cachetools` caches. If `logger` is `None`, the root logger from
350
+ `logging.getLogger()` is used.
351
+
352
+ `LRUCacheManager(maxsize, ...)` creates an `LRUCache`, a `LoggedCache`, an
353
+ `RLock`, and a shared `CacheStats` object.
354
+
355
+ `TTLCacheManager(maxsize, ttl, ...)` does the same for `TTLCache`.
356
+
357
+ `AsyncLRUCacheManager(maxsize, ...)` and `AsyncTTLCacheManager(maxsize, ttl, ...)`
358
+ use `asyncio.Lock` for async-only applications.
359
+
360
+ `cached(manager, key=cachetools.keys.hashkey, info=False)` returns a decorator
361
+ compatible with regular and async functions.
362
+
363
+ `cachedmethod(manager, key=cachetools.keys.methodkey, info=False)` returns a
364
+ decorator compatible with regular and async methods.
365
+
366
+ `async_cached(...)` and `async_cachedmethod(...)` are async-only variants for
367
+ async cache managers.
368
+
369
+ ## Development
370
+
371
+ ```bash
372
+ uv sync --extra dev --extra docs
373
+ uv run ruff check .
374
+ uv run pylint src/logged_cache tests
375
+ uv run mypy
376
+ uv run pytest
377
+ uv run python -m build
378
+ uv run twine check dist/*
379
+ ```
380
+
381
+ Install pre-commit hooks with:
382
+
383
+ ```bash
384
+ uv run pre-commit install
385
+ ```
386
+
387
+ ## License
388
+
389
+ MIT