diff --git a/README.md b/README.md index 23fa6a3..2b94450 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,11 @@ info = expensive_func.cache_info() | [Distributed Locking][distributed-locking-url] | Cache stampede prevention | | [Prometheus Metrics][prometheus-url] | Built-in observability | | [Zero-Knowledge Encryption][encryption-url] | Client-side security | +| [Interop Mode][interop-url] | Cross-SDK cache sharing with cachekit-ts/rs | +| [L1 Invalidation & SWR][l1-invalidation-url] | Process-local invalidation, stale-while-revalidate | +| [Reference Caching][reference-caching-url] | `@cache.local()` for non-serializable objects | +| [Rust Serialization][rust-serialization-url] | ByteStorage layer: LZ4, xxHash3, AES-256-GCM | +| [SSRF Protection][ssrf-url] | URL allowlisting for the CachekitIO backend | --- @@ -479,6 +484,11 @@ MIT License - see [LICENSE][license-file-url] for details. [distributed-locking-url]: docs/features/distributed-locking.md [prometheus-url]: docs/features/prometheus-metrics.md [encryption-url]: docs/features/zero-knowledge-encryption.md +[interop-url]: docs/features/interop-mode.md +[l1-invalidation-url]: docs/features/l1-invalidation.md +[reference-caching-url]: docs/features/reference-caching.md +[rust-serialization-url]: docs/features/rust-serialization.md +[ssrf-url]: docs/features/ssrf-protection.md [contributing-url]: CONTRIBUTING.md [license-file-url]: LICENSE [github-url]: https://github.com/cachekit-io/cachekit-py diff --git a/docs/README.md b/docs/README.md index fd99e40..3ed3b97 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,11 @@ Choose how data is stored: | [Distributed Locking](features/distributed-locking.md) | Prevent thundering herd | | [Zero-Knowledge Encryption](features/zero-knowledge-encryption.md) | Client-side AES-256-GCM | | [Prometheus Metrics](features/prometheus-metrics.md) | Production observability | +| [Interop Mode](features/interop-mode.md) | Cross-SDK cache sharing with cachekit-ts/rs | +| [L1 Invalidation & SWR](features/l1-invalidation.md) | Process-local invalidation, stale-while-revalidate | +| [Reference Caching](features/reference-caching.md) | `@cache.local()` for non-serializable objects | +| [Rust Serialization](features/rust-serialization.md) | ByteStorage layer: LZ4, xxHash3, AES-256-GCM | +| [SSRF Protection](features/ssrf-protection.md) | URL allowlisting for the CachekitIO backend | ## Architecture & Reference diff --git a/llms.txt b/llms.txt index 6e2e122..7055653 100644 --- a/llms.txt +++ b/llms.txt @@ -179,6 +179,11 @@ CACHEKIT_MASTER_KEY=hex-encoded-32-byte-key - [Distributed Locking](docs/features/distributed-locking.md): Cache stampede prevention - [Zero-Knowledge Encryption](docs/features/zero-knowledge-encryption.md): Client-side AES-256-GCM - [Prometheus Metrics](docs/features/prometheus-metrics.md): Built-in observability +- [Interop Mode](docs/features/interop-mode.md): Cross-SDK cache sharing (interop/v1) +- [L1 Invalidation & SWR](docs/features/l1-invalidation.md): Process-local invalidation, stale-while-revalidate +- [Reference Caching](docs/features/reference-caching.md): @cache.local() for non-serializable objects +- [Rust Serialization](docs/features/rust-serialization.md): ByteStorage layer (LZ4, xxHash3, AES-256-GCM) +- [SSRF Protection](docs/features/ssrf-protection.md): CachekitIO URL allowlisting - [Architecture](docs/data-flow-architecture.md): L1+L2 internals ### Development diff --git a/tests/unit/test_feature_guide_index.py b/tests/unit/test_feature_guide_index.py new file mode 100644 index 0000000..14d44af --- /dev/null +++ b/tests/unit/test_feature_guide_index.py @@ -0,0 +1,107 @@ +"""Guard against feature-guide index drift (LAB-1013). + +Every guide in docs/features/*.md must be reachable from the repo's index +surfaces. Historically guides were "born orphaned": five of nine were listed +in no index at all because nothing checked reachability. This test is that +check — a new guide added without index links fails here with its filename. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# All three surfaces index the full guide set (deliberate call on LAB-1013; +# the top-level README is NOT a curated subset). Each maps to the exact link +# prefix a guide path must carry ON THAT SURFACE to resolve for a reader — +# docs/README.md links are relative to docs/, the other two to the repo root. +# A shared optional prefix would count a link that 404s on its own surface +# (e.g. docs/features/x.md written inside docs/README.md) as indexed. +INDEX_FILES = { + "README.md": "docs/features/", + "docs/README.md": "features/", + "llms.txt": "docs/features/", +} + + +def _strip_non_rendered(text: str) -> str: + """Remove markdown content that never renders: fenced code blocks and HTML comments. + + A link-shaped string inside either would satisfy the regexes below without + being reachable by a reader. Backtick fences only — that is what these + index files use. One alternation, not two passes: whichever construct + opens first consumes the other, matching how markdown resolves the overlap. + """ + text = re.sub(r"```.*?```|", "", text, flags=re.DOTALL) + # An odd fence count skews the non-greedy pairing and silently un-strips a + # block — the exact false-pass this helper exists to prevent. Fail loud. + assert "```" not in text, "unpaired ``` fence — stripping unreliable" + return text + + +def _is_linked(index_text: str, guide_name: str, prefix: str) -> bool: + """True if the guide is reachable as a rendered link in the index text. + + ``prefix`` is the surface's exact link prefix from INDEX_FILES, anchored + to the link target's start — so an offsite same-suffix URL or a + wrong-prefix path that 404s on this surface does not count. A trailing + ``#fragment`` or ``?query`` still resolves to the same file, so it counts; + any other character after ``.md`` does not. Two link forms exist: + - inline: ``[Name](docs/features/x.md)`` + - reference-style (README.md): ``[Name][label]`` + ``[label]: docs/features/x.md``. + A definition whose label is never used renders as nothing, so the bare + path substring is not enough — the label must appear as ``][label]``. + """ + index_text = _strip_non_rendered(index_text) + target = re.escape(f"{prefix}{guide_name}") + r"(?:[#?][^)\s]*)?" + if re.search(rf"\]\({target}\)", index_text): + return True + for m in re.finditer(rf"^\[([^\]]+)\]:\s*{target}\s*$", index_text, re.MULTILINE): + if f"][{m.group(1)}]" in index_text: + return True + return False + + +def test_is_linked_counts_rendered_links_only(): + """Link-shaped text in fenced code or HTML comments must not satisfy the guard.""" + assert _is_linked("[X](docs/features/x.md)", "x.md", "docs/features/") + assert _is_linked("See [X][x-url].\n\n[x-url]: docs/features/x.md", "x.md", "docs/features/") + assert not _is_linked("```\n[X](docs/features/x.md)\n```", "x.md", "docs/features/") + assert not _is_linked("", "x.md", "docs/features/") + # Definition never used renders as nothing. + assert not _is_linked("[x-url]: docs/features/x.md", "x.md", "docs/features/") + + +def test_is_linked_accepts_fragment_and_query_suffixes(): + """#fragment / ?query target the same file — indexed; other suffixes are not it.""" + assert _is_linked("[X](docs/features/x.md#anchor)", "x.md", "docs/features/") + assert _is_linked("[X](docs/features/x.md?plain=1)", "x.md", "docs/features/") + assert _is_linked("See [X][x].\n\n[x]: docs/features/x.md#anchor", "x.md", "docs/features/") + # A longer filename sharing the prefix is a different file. + assert not _is_linked("[X](docs/features/x.mdx)", "x.md", "docs/features/") + + +def test_is_linked_requires_the_surfaces_own_prefix(): + """A link that 404s on its own surface must not count as indexed. + + These are the two realistic copy-paste-between-surfaces mistakes: + a root-relative path inside docs/README.md and a docs-relative path + inside the top-level README. + """ + assert not _is_linked("[X](docs/features/x.md)", "x.md", "features/") + assert not _is_linked("[X](features/x.md)", "x.md", "docs/features/") + + +@pytest.mark.parametrize(("index_file", "prefix"), sorted(INDEX_FILES.items())) +def test_every_feature_guide_is_indexed(index_file: str, prefix: str): + """Each docs/features/*.md must be linked from every index surface.""" + guides = sorted((REPO_ROOT / "docs" / "features").glob("*.md")) + assert guides, "docs/features/ contains no guides — glob path broken?" + + index_text = (REPO_ROOT / index_file).read_text(encoding="utf-8") + orphans = [g.name for g in guides if not _is_linked(index_text, g.name, prefix)] + assert not orphans, f"Feature guides missing from {index_file}: {orphans}" diff --git a/uv.lock b/uv.lock index 7b71ffb..fa54916 100644 --- a/uv.lock +++ b/uv.lock @@ -593,7 +593,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -655,15 +655,15 @@ wheels = [ [[package]] name = "h2" -version = "4.3.0" +version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, ] [[package]] @@ -754,11 +754,11 @@ wheels = [ [[package]] name = "hpack" -version = "4.1.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, ] [[package]]