diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c68258..42e1f82 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,13 +28,44 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - - name: Install with dev extra + - name: Install with dev + authoring extras run: | python -m pip install --upgrade pip - python -m pip install -e ".[dev]" + python -m pip install -e ".[dev,authoring]" - name: Run ruff run: python -m ruff check src/ tests/ - - name: Run tests + - name: Run tests (with coverage on ubuntu x py3.11) + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ] && [ "${{ matrix.python-version }}" = "3.11" ]; then + python -m pytest tests/ -v --tb=short --cov --cov-report=term-missing --cov-report=xml + else + python -m pytest tests/ -v --tb=short + fi + shell: bash + + - name: Upload coverage artifact + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: actions/upload-artifact@v4 + with: + name: coverage-attune-help + path: coverage.xml + + test-no-authoring-extra: + name: zero-dep contract (no [authoring]) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + cache: pip + - name: Install with dev extra ONLY (no authoring) + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + - name: Run tests; shim tests must skip cleanly via importorskip run: python -m pytest tests/ -v --tb=short diff --git a/pyproject.toml b/pyproject.toml index 72a817b..463f172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,18 +27,20 @@ classifiers = [ ] dependencies = [ "python-frontmatter>=1.0.0", - # Transitional: manifest/staleness/freshness moved to attune-author - # in 0.11.0. ``attune_help.manifest``/``staleness``/``freshness`` - # are deprecated shims that re-export from attune-author. Required - # so existing imports keep working through the deprecation window. - # Drop this dep when the shims are removed (target 2026-07-07) and - # restore tech.md's zero-required-deps constraint. - "attune-author>=0.7.0", ] [project.optional-dependencies] rich = ["rich>=13.0.0"] plugin = ["mcp>=0.9.0"] +# Transitional: manifest/staleness/freshness moved to attune-author in +# 0.11.0. ``attune_help.manifest``/``staleness``/``freshness`` are +# deprecated shims that re-export from attune-author. Install with +# ``pip install attune-help[authoring]`` to keep the shims working. +# Restored tech.md ADR-002 (zero required deps) on 2026-05-08. +# Sunset target: 2026-07-07 — drop the shims and this extra entirely. +authoring = [ + "attune-author>=0.7.0", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", @@ -61,3 +63,26 @@ where = ["src"] [tool.setuptools.package-data] attune_help = ["templates/**/*.md", "templates/**/*.json", "demos/**/*.md"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" +markers = [ + "slow: tests that create real venvs or otherwise take >1s", +] + +[tool.coverage.run] +source = ["src/attune_help"] +branch = true +omit = ["*/tests/*", "*/conftest.py"] + +[tool.coverage.report] +show_missing = true +skip_covered = false +fail_under = 81 +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] diff --git a/src/attune_help/freshness/__init__.py b/src/attune_help/freshness/__init__.py index 6007092..c1563a0 100644 --- a/src/attune_help/freshness/__init__.py +++ b/src/attune_help/freshness/__init__.py @@ -16,7 +16,14 @@ import warnings -from attune_author.freshness import SymbolExtractor, SymbolRecord +try: + from attune_author.freshness import SymbolExtractor, SymbolRecord +except ImportError as exc: # pragma: no cover + raise ImportError( + "attune_help.freshness is a deprecated shim that requires the " + "'authoring' extra. Install with: pip install attune-help[authoring]. " + "Or migrate your imports to attune_author.freshness directly." + ) from exc __all__ = ["SymbolExtractor", "SymbolRecord"] diff --git a/src/attune_help/freshness/symbols.py b/src/attune_help/freshness/symbols.py index 7c2b212..38fe5a7 100644 --- a/src/attune_help/freshness/symbols.py +++ b/src/attune_help/freshness/symbols.py @@ -16,7 +16,14 @@ import warnings -from attune_author.freshness.symbols import SymbolExtractor, SymbolRecord +try: + from attune_author.freshness.symbols import SymbolExtractor, SymbolRecord +except ImportError as exc: # pragma: no cover + raise ImportError( + "attune_help.freshness.symbols is a deprecated shim that requires " + "the 'authoring' extra. Install with: pip install attune-help[authoring]. " + "Or migrate your imports to attune_author.freshness.symbols directly." + ) from exc __all__ = ["SymbolExtractor", "SymbolRecord"] diff --git a/src/attune_help/manifest.py b/src/attune_help/manifest.py index de949f0..9e285f1 100644 --- a/src/attune_help/manifest.py +++ b/src/attune_help/manifest.py @@ -16,17 +16,24 @@ import warnings -from attune_author.manifest import ( - Feature, - FeatureManifest, - Manifest, - is_safe_feature_name, - load_manifest, - match_files_to_features, - resolve_topic, - save_manifest, - slugify, -) +try: + from attune_author.manifest import ( + Feature, + FeatureManifest, + Manifest, + is_safe_feature_name, + load_manifest, + match_files_to_features, + resolve_topic, + save_manifest, + slugify, + ) +except ImportError as exc: # pragma: no cover + raise ImportError( + "attune_help.manifest is a deprecated shim that requires the " + "'authoring' extra. Install with: pip install attune-help[authoring]. " + "Or migrate your imports to attune_author.manifest directly." + ) from exc __all__ = [ "Feature", diff --git a/src/attune_help/staleness.py b/src/attune_help/staleness.py index 99d788d..b665083 100644 --- a/src/attune_help/staleness.py +++ b/src/attune_help/staleness.py @@ -16,16 +16,23 @@ import warnings -from attune_author.staleness import ( - DocStaleness, - FeatureStaleness, - StalenessReport, - build_doc_footer, - check_staleness, - compute_semantic_hash, - compute_source_hash, - parse_doc_footer, -) +try: + from attune_author.staleness import ( + DocStaleness, + FeatureStaleness, + StalenessReport, + build_doc_footer, + check_staleness, + compute_semantic_hash, + compute_source_hash, + parse_doc_footer, + ) +except ImportError as exc: # pragma: no cover + raise ImportError( + "attune_help.staleness is a deprecated shim that requires the " + "'authoring' extra. Install with: pip install attune-help[authoring]. " + "Or migrate your imports to attune_author.staleness directly." + ) from exc __all__ = [ "DocStaleness", diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c318048 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,61 @@ +# attune-help tests + +## Running locally + +```bash +# Full suite (requires the deprecated authoring shims to resolve) +pip install -e ".[dev,authoring]" +pytest + +# Zero-dep contract: shim tests skip via pytest.importorskip +pip install -e ".[dev]" +pytest + +# With coverage (matches CI's ubuntu x py3.11 cell) +pip install -e ".[dev,authoring]" +pytest --cov --cov-report=term-missing +``` + +The `slow` marker covers tests that create real venvs +(`test_zero_dep_install.py`). Skip with `pytest -m "not slow"` for fast +iteration. + +## LLM mocking standard + +attune-help itself makes no LLM calls. Cross-layer integration tests that +*could* exercise an LLM follow the **attune-author reference pattern**: + +- Strip `ANTHROPIC_API_KEY` via an autouse fixture so a misconfigured + test never reaches the network. +- Patch `anthropic.Anthropic` at import time, not at call site. +- Reset module-level singletons (e.g. `_RagPipeline`) between tests with + an autouse fixture so a leaked patch doesn't poison later tests. + +See `attune-author/tests/conftest.py` (`_lenient_polish_by_default`, +`_reset_rag_pipeline`). Pass 2 of the test-strategy spec will formalize +this into a shared `docs/testing-conventions.md` across layers. + +## What's tested vs. not + +Tracked in +`/Users/patrickroebuck/attune/specs/test-strategy/current-state.md`. After +pass 1, the highest-value remaining gaps in this layer are: + +- `cli.py` (~70% branch coverage) — argparse error paths +- `engine.py` (~86%) — depth-progression edge cases +- `transformers.py` (~78%) — channel-specific render branches + +Pass 2 will revisit thresholds and target those areas if needed. + +## Architectural contracts under test + +- **`tests/test_zero_dep_install.py`** — guards + [tech.md](../../tech.md) ADR-002 (zero required deps beyond + `python-frontmatter`). Spins up a fresh venv per test case; marked + `@pytest.mark.slow`. +- **`tests/test_storage_protocol.py`** — reusable mixin verifying the + `SessionStorage` Protocol contract. New backends (Redis, DB) inherit + from `StorageProtocolTester` and override `_make_storage()`. +- **`tests/test_authoring_shims.py`** — verifies the deprecated re-export + shims still resolve correctly while the `[authoring]` extra is + installed. Sunset 2026-07-07. diff --git a/tests/test_adapter_rag.py b/tests/test_adapter_rag.py new file mode 100644 index 0000000..19751f7 --- /dev/null +++ b/tests/test_adapter_rag.py @@ -0,0 +1,64 @@ +"""Tests for attune_help.adapters.rag — the attune-rag corpus adapter.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +import attune_help +from attune_help.adapters.rag import AttuneHelpAdapter + + +def test_default_templates_root_points_to_bundled_dir() -> None: + adapter = AttuneHelpAdapter() + assert ( + adapter.templates_root.is_dir() + ), f"bundled templates dir missing: {adapter.templates_root}" + # Must live inside the installed package, not somewhere arbitrary. + package_root = Path(attune_help.__file__).resolve().parent + assert package_root in adapter.templates_root.resolve().parents + + +def test_default_version_matches_package_version() -> None: + adapter = AttuneHelpAdapter() + assert adapter.version == attune_help.__version__ + + +def test_custom_root_and_version_override_defaults(tmp_path: Path) -> None: + custom_root = tmp_path / "custom-corpus" + custom_root.mkdir() + adapter = AttuneHelpAdapter( + templates_root=custom_root, + version="9.9.9-test", + ) + assert adapter.templates_root == custom_root + assert adapter.version == "9.9.9-test" + + +def test_adapter_is_frozen() -> None: + adapter = AttuneHelpAdapter() + with pytest.raises(FrozenInstanceError): + adapter.version = "0.0.0" # type: ignore[misc] + + +def test_adapter_satisfies_help_corpus_adapter_protocol() -> None: + """Structural conformance to attune-rag's HelpCorpusAdapter protocol. + + The protocol requires ``templates_root: Path`` and ``version: str``. + We assert the shape directly instead of importing the protocol so + this test runs even when attune-rag isn't installed. + """ + adapter = AttuneHelpAdapter() + assert isinstance(adapter.templates_root, Path) + assert isinstance(adapter.version, str) + assert adapter.version # non-empty + + +def test_two_adapters_with_same_inputs_are_equal(tmp_path: Path) -> None: + a = AttuneHelpAdapter(templates_root=tmp_path, version="1.0") + b = AttuneHelpAdapter(templates_root=tmp_path, version="1.0") + assert a == b + # frozen dataclass instances are hashable. + assert hash(a) == hash(b) diff --git a/tests/test_authoring_shims.py b/tests/test_authoring_shims.py index 26aece6..438ee8e 100644 --- a/tests/test_authoring_shims.py +++ b/tests/test_authoring_shims.py @@ -15,6 +15,8 @@ import pytest +pytest.importorskip("attune_author") # Shim tests require the [authoring] extra. + SHIMS = [ "attune_help.manifest", "attune_help.staleness", diff --git a/tests/test_manifest.py b/tests/test_manifest.py index cafadde..e5c1eff 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -5,6 +5,9 @@ from pathlib import Path import pytest + +pytest.importorskip("attune_author") # Shim tests require the [authoring] extra. + from attune_help.manifest import ( Feature, FeatureManifest, diff --git a/tests/test_mcp_handlers.py b/tests/test_mcp_handlers.py new file mode 100644 index 0000000..17074e2 --- /dev/null +++ b/tests/test_mcp_handlers.py @@ -0,0 +1,146 @@ +"""Direct unit tests for attune_help.mcp.handlers + mcp.server. + +The existing ``test_mcp_server.py`` exercises handlers indirectly via +the server. This file targets the pieces that are easier to verify in +isolation: the ``_require_str`` validator, the engine builder error +paths, and the server-module factory + singleton. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from attune_help.mcp import server as server_mod +from attune_help.mcp.handlers import AttuneHelpHandlers, _require_str +from attune_help.mcp.server import ( + AttuneHelpMCPServer, + _get_app, + create_server, +) + +# --------------------------------------------------------------------------- +# _require_str validator +# --------------------------------------------------------------------------- + + +def test_require_str_returns_value_when_present() -> None: + value, err = _require_str({"topic": "auth"}, "topic") + assert value == "auth" + assert err is None + + +def test_require_str_errors_when_missing_required() -> None: + value, err = _require_str({}, "topic") + assert value is None + assert err == { + "success": False, + "error": "topic is required and must be a string", + } + + +def test_require_str_returns_none_for_missing_optional() -> None: + value, err = _require_str({}, "topic", optional=True) + assert value is None + assert err is None + + +def test_require_str_errors_on_empty_string() -> None: + value, err = _require_str({"topic": ""}, "topic") + assert value is None + assert err is not None + assert err["success"] is False + assert "topic" in err["error"] + + +def test_require_str_errors_on_non_string() -> None: + value, err = _require_str({"topic": 42}, "topic") + assert value is None + assert err is not None + assert err["success"] is False + + +def test_require_str_optional_rejects_empty_when_provided() -> None: + value, err = _require_str({"topic": ""}, "topic", optional=True) + assert value is None + assert err is not None + assert "when provided" in err["error"] + + +# --------------------------------------------------------------------------- +# AttuneHelpHandlers._engine path validation +# --------------------------------------------------------------------------- + + +def test_engine_uses_bundled_when_no_template_dir(tmp_path: Path) -> None: + handlers = AttuneHelpHandlers(workspace_root=str(tmp_path)) + engine = handlers._engine(template_dir=None) + assert engine is not None + + +def test_engine_validates_template_dir_against_workspace(tmp_path: Path) -> None: + """Path traversal outside workspace_root must be rejected.""" + handlers = AttuneHelpHandlers(workspace_root=str(tmp_path)) + custom = tmp_path / "templates" + custom.mkdir() + # In-bounds path resolves cleanly. + engine = handlers._engine(template_dir=str(custom)) + assert engine is not None + + +def test_engine_rejects_traversal_template_dir(tmp_path: Path) -> None: + handlers = AttuneHelpHandlers(workspace_root=str(tmp_path)) + with pytest.raises(Exception): + # Traversal attempts must surface as an error rather than + # silently resolve outside the workspace. + handlers._engine(template_dir="../../etc/passwd") + + +# --------------------------------------------------------------------------- +# server module factory + singleton +# --------------------------------------------------------------------------- + + +def test_create_server_returns_fresh_instance() -> None: + a = create_server() + b = create_server() + assert isinstance(a, AttuneHelpMCPServer) + assert isinstance(b, AttuneHelpMCPServer) + # Factory yields separate objects each call. + assert a is not b + + +def test_get_app_caches_singleton() -> None: + # Reset the module-level cache so this test is self-contained. + server_mod._app = None + first = _get_app() + second = _get_app() + assert first is second + server_mod._app = None # restore for subsequent tests + + +def test_server_exposes_tools_dict() -> None: + app = create_server() + assert isinstance(app.tools, dict) + assert app.tools, "server should register at least one tool" + for name, defn in app.tools.items(): + assert name.startswith( + "lookup_" + ), f"tool {name!r} does not match the lookup_* prefix contract" + assert "description" in defn or "input_schema" in defn + + +# --------------------------------------------------------------------------- +# Async dispatch — ensure call_tool wrapping handles unknown tools cleanly +# --------------------------------------------------------------------------- + + +def test_call_tool_unknown_tool_returns_error() -> None: + """Server must not crash on an unknown tool name; should return an error dict.""" + app = create_server() + result = asyncio.run(app.call_tool("not_a_tool", {})) + assert isinstance(result, dict) + assert result.get("success") is False + assert "error" in result diff --git a/tests/test_staleness.py b/tests/test_staleness.py index a42e21c..eac6890 100644 --- a/tests/test_staleness.py +++ b/tests/test_staleness.py @@ -4,6 +4,10 @@ from pathlib import Path +import pytest + +pytest.importorskip("attune_author") # Shim tests require the [authoring] extra. + from attune_help.manifest import Feature, FeatureManifest from attune_help.staleness import ( DocStaleness, @@ -138,9 +142,7 @@ def test_compute_source_hash_deterministic(tmp_path: Path): def test_compute_source_hash_changes_on_edit(tmp_path: Path): - f = _write_src( - tmp_path, "src/auth/login.py", "def login(name: str) -> str:\n return name\n" - ) + f = _write_src(tmp_path, "src/auth/login.py", "def login(name: str) -> str:\n return name\n") feat = Feature(name="auth", description="", files=["src/auth/**"]) h1, _ = compute_source_hash(feat, tmp_path) f.write_text( @@ -160,7 +162,7 @@ def test_compute_source_hash_stable_across_docstring_edit(tmp_path: Path): feat = Feature(name="auth", description="", files=["src/auth/**"]) h1, _ = compute_source_hash(feat, tmp_path) f.write_text( - 'def login(name: str) -> str:\n """Very detailed login documentation.\"\"\"\n return name\n', + 'def login(name: str) -> str:\n """Very detailed login documentation."""\n return name\n', encoding="utf-8", ) h2, _ = compute_source_hash(feat, tmp_path) @@ -169,14 +171,10 @@ def test_compute_source_hash_stable_across_docstring_edit(tmp_path: Path): def test_compute_source_hash_changes_on_signature_edit(tmp_path: Path): """Semantic hashing: adding a parameter changes the hash.""" - f = _write_src( - tmp_path, "src/auth/login.py", "def login(name: str) -> str:\n return name\n" - ) + f = _write_src(tmp_path, "src/auth/login.py", "def login(name: str) -> str:\n return name\n") feat = Feature(name="auth", description="", files=["src/auth/**"]) h1, _ = compute_source_hash(feat, tmp_path) - f.write_text( - "def login(name: str, password: str) -> str:\n return name\n", encoding="utf-8" - ) + f.write_text("def login(name: str, password: str) -> str:\n return name\n", encoding="utf-8") h2, _ = compute_source_hash(feat, tmp_path) assert h1 != h2 diff --git a/tests/test_storage_protocol.py b/tests/test_storage_protocol.py new file mode 100644 index 0000000..1a30d17 --- /dev/null +++ b/tests/test_storage_protocol.py @@ -0,0 +1,121 @@ +"""SessionStorage protocol verifier. + +Reusable mixin that exercises the documented contract of +:class:`attune_help.storage.SessionStorage`. Any backend (LocalFileStorage, +Redis, database, in-memory) can plug into the mixin by implementing +``_make_storage()`` and inherit a uniform contract suite. + +This guards the protocol surface so future backends (e.g. Redis) can be +verified against the same expectations rather than re-deriving them. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict + + +from attune_help.storage import LocalFileStorage, SessionStorage + + +class _InMemoryStorage: + """Reference in-memory implementation of the SessionStorage protocol. + + Exists primarily to drive the protocol verifier — proves the mixin + itself is implementation-agnostic. + """ + + def __init__(self) -> None: + self._data: Dict[str, dict[str, Any]] = {} + + def get_session(self, user_id: str) -> dict[str, Any]: + if user_id in self._data: + return dict(self._data[user_id]) + return { + "last_topic": None, + "depth_level": 0, + "topics": {}, + "order": [], + } + + def set_session(self, user_id: str, state: dict[str, Any]) -> None: + self._data[user_id] = dict(state) + + +class StorageProtocolTester(ABC): + """Mixin that asserts SessionStorage protocol conformance. + + Subclass and implement ``_make_storage()`` to run the full contract + suite against your backend. + """ + + @abstractmethod + def _make_storage(self, tmp_path: Path) -> SessionStorage: + """Return a fresh storage instance for one test.""" + + def test_returns_defaults_for_unknown_user(self, tmp_path: Path) -> None: + storage = self._make_storage(tmp_path) + result = storage.get_session("nobody") + assert result == { + "last_topic": None, + "depth_level": 0, + "topics": {}, + "order": [], + } + + def test_round_trip_preserves_topic_and_depth(self, tmp_path: Path) -> None: + storage = self._make_storage(tmp_path) + state = { + "last_topic": "auth", + "depth_level": 2, + "topics": {"auth": 2}, + "order": ["auth"], + } + storage.set_session("alice", state) + loaded = storage.get_session("alice") + assert loaded["last_topic"] == "auth" + assert loaded["depth_level"] == 2 + assert loaded["topics"] == {"auth": 2} + assert loaded["order"] == ["auth"] + + def test_users_are_isolated(self, tmp_path: Path) -> None: + storage = self._make_storage(tmp_path) + storage.set_session( + "alice", + {"last_topic": "a", "depth_level": 1, "topics": {"a": 1}, "order": ["a"]}, + ) + storage.set_session( + "bob", + {"last_topic": "b", "depth_level": 3, "topics": {"b": 3}, "order": ["b"]}, + ) + assert storage.get_session("alice")["last_topic"] == "a" + assert storage.get_session("bob")["last_topic"] == "b" + + def test_set_then_overwrite_replaces_state(self, tmp_path: Path) -> None: + storage = self._make_storage(tmp_path) + storage.set_session( + "alice", + {"last_topic": "a", "depth_level": 1, "topics": {"a": 1}, "order": ["a"]}, + ) + storage.set_session( + "alice", + {"last_topic": "b", "depth_level": 2, "topics": {"b": 2}, "order": ["b"]}, + ) + loaded = storage.get_session("alice") + assert loaded["last_topic"] == "b" + assert loaded["topics"] == {"b": 2} + + +class TestLocalFileStorageProtocol(StorageProtocolTester): + """Run the protocol suite against LocalFileStorage.""" + + def _make_storage(self, tmp_path: Path) -> SessionStorage: + return LocalFileStorage(storage_dir=tmp_path / "sessions") + + +class TestInMemoryStorageProtocol(StorageProtocolTester): + """Run the protocol suite against the reference in-memory backend.""" + + def _make_storage(self, tmp_path: Path) -> SessionStorage: + return _InMemoryStorage() diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 4eb573e..7e05d11 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -4,6 +4,7 @@ Each test maps to a numbered acceptance criterion in docs/specs/phase-1-semantic-freshness-spec.md. """ + from __future__ import annotations import textwrap @@ -11,6 +12,8 @@ import pytest +pytest.importorskip("attune_author") # Shim tests require the [authoring] extra. + from attune_help.freshness import SymbolExtractor @@ -128,14 +131,12 @@ def farewell(name: str) -> str: # Now change `greet` only file.write_text( - textwrap.dedent( - """ + textwrap.dedent(""" def greet(name: str, formal: bool = False) -> str: return name def farewell(name: str) -> str: return name - """ - ), + """), encoding="utf-8", ) greet_v2 = extractor.extract_one(file, "greet") diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..1660968 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,184 @@ +"""Tests for attune_help.templates — file resolution, parsing, caching.""" + +from __future__ import annotations + +import os +import time +from pathlib import Path + +import pytest + +from attune_help.templates import ( + _find_template_file, + _parse_template_file, + _PARSED_TEMPLATE_CACHE, + invalidate_cross_links_cache, + invalidate_template_cache, +) + + +@pytest.fixture(autouse=True) +def _clear_caches() -> None: + invalidate_template_cache() + invalidate_cross_links_cache() + + +def _write_template( + base: Path, + template_id: str, + *, + body: str = "Body text.", + title: str = "Sample", + extra_frontmatter: str = "", +) -> Path: + """Write a minimal markdown template with frontmatter.""" + prefix, name = template_id.split("-", 1) + type_dir = { + "com": "comparisons", + "con": "concepts", + "err": "errors", + "faq": "faqs", + "gui": "guides", + "not": "notes", + "qui": "quickstarts", + "ref": "references", + "tas": "tasks", + "tip": "tips", + "tro": "troubleshooting", + "war": "warnings", + }[prefix] + target = base / type_dir + target.mkdir(parents=True, exist_ok=True) + file = target / f"{name}.md" + fm_block = f'name: "{name}"\nsubtype: "test"\n{extra_frontmatter}'.strip() + file.write_text( + f"---\n{fm_block}\n---\n\n# {title}\n\n## Overview\n\n{body}\n", + encoding="utf-8", + ) + return file + + +# --------------------------------------------------------------------------- +# _find_template_file +# --------------------------------------------------------------------------- + + +def test_find_template_file_resolves_known_prefix(tmp_path: Path) -> None: + f = _write_template(tmp_path, "con-auth") + found = _find_template_file("con-auth", tmp_path) + assert found == f + + +def test_find_template_file_returns_none_for_unknown_prefix(tmp_path: Path) -> None: + assert _find_template_file("xyz-anything", tmp_path) is None + + +def test_find_template_file_returns_none_for_malformed_id(tmp_path: Path) -> None: + assert _find_template_file("noseparator", tmp_path) is None + assert _find_template_file("", tmp_path) is None + + +def test_find_template_file_returns_none_when_missing(tmp_path: Path) -> None: + # Valid prefix but file does not exist. + assert _find_template_file("con-missing", tmp_path) is None + + +def test_find_template_file_blocks_path_traversal(tmp_path: Path) -> None: + """CWE-22 guard: ../ in template id must not escape generated_dir.""" + # Create a victim file outside the intended dir. + outside = tmp_path.parent / "outside.md" + outside.write_text("secret", encoding="utf-8") + try: + # Attempt traversal via slashed name segment. + result = _find_template_file("con-../outside", tmp_path) + assert result is None + finally: + outside.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# _parse_template_file +# --------------------------------------------------------------------------- + + +def test_parse_extracts_frontmatter_and_sections(tmp_path: Path) -> None: + f = _write_template( + tmp_path, + "con-foo", + body="Some content.", + title="Foo Title", + extra_frontmatter='tags: ["a", "b"]\n', + ) + parsed = _parse_template_file(f) + assert parsed["title"] == "Foo Title" + assert parsed["name"] == "foo" + assert parsed["subtype"] == "test" + assert parsed["tags"] == ["a", "b"] + assert "Some content." in parsed["sections"]["Overview"] + + +def test_parse_handles_string_tags(tmp_path: Path) -> None: + f = _write_template( + tmp_path, + "con-bar", + extra_frontmatter='tags: "x, y, z"\n', + ) + parsed = _parse_template_file(f) + assert parsed["tags"] == ["x", "y", "z"] + + +def test_parse_uses_filename_when_name_missing(tmp_path: Path) -> None: + target = tmp_path / "concepts" + target.mkdir(parents=True, exist_ok=True) + f = target / "lonely.md" + f.write_text("---\nsubtype: test\n---\n\n# Lonely\n", encoding="utf-8") + parsed = _parse_template_file(f) + assert parsed["name"] == "lonely" + + +# --------------------------------------------------------------------------- +# Cache behavior +# --------------------------------------------------------------------------- + + +def test_parse_caches_by_mtime(tmp_path: Path) -> None: + f = _write_template(tmp_path, "con-cached", body="v1") + parsed_a = _parse_template_file(f) + # Cache should now contain an entry for this file. + assert any(str(f) == k[0] for k in _PARSED_TEMPLATE_CACHE.keys()) + # Parsing again returns the same object identity (cache hit). + parsed_b = _parse_template_file(f) + assert parsed_a is parsed_b + + +def test_parse_cache_invalidates_when_mtime_changes(tmp_path: Path) -> None: + f = _write_template(tmp_path, "con-mutate", body="v1") + first = _parse_template_file(f) + assert "v1" in first["sections"]["Overview"] + + # Bump mtime by writing fresh content with a future timestamp. + f.write_text( + "---\nname: mutate\nsubtype: test\n---\n\n# T\n\n## Overview\n\nv2\n", + encoding="utf-8", + ) + future = time.time() + 5 + os.utime(f, (future, future)) + + second = _parse_template_file(f) + assert second is not first + assert "v2" in second["sections"]["Overview"] + + +def test_invalidate_template_cache_clears_all(tmp_path: Path) -> None: + f = _write_template(tmp_path, "con-x") + _parse_template_file(f) + assert _PARSED_TEMPLATE_CACHE + invalidate_template_cache() + assert not _PARSED_TEMPLATE_CACHE + + +def test_parse_handles_unreadable_path(tmp_path: Path) -> None: + """When stat() fails, parser should fall through to the raw load path.""" + nonexistent = tmp_path / "ghost.md" + with pytest.raises(FileNotFoundError): + _parse_template_file(nonexistent) diff --git a/tests/test_zero_dep_install.py b/tests/test_zero_dep_install.py new file mode 100644 index 0000000..6fe4366 --- /dev/null +++ b/tests/test_zero_dep_install.py @@ -0,0 +1,98 @@ +"""Verify attune-help honors its zero-required-deps contract (ADR-002). + +Spins up a fresh venv with only ``python-frontmatter`` installed, installs +attune-help via ``--no-deps``, and asserts: + +(a) ``import attune_help`` succeeds. +(b) ``import attune_help.manifest`` fails with a helpful ImportError + that mentions the ``authoring`` extra. + +This guards the architectural contract documented in ``tech.md`` +ADR-002. Marked ``slow`` because it creates a real venv. +""" + +from __future__ import annotations + +import subprocess +import sys +import venv +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +@pytest.mark.slow +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="venv layout differs on Windows; covered on Linux/macOS", +) +def test_attune_help_imports_without_authoring_extra(tmp_path: Path) -> None: + venv_dir = tmp_path / "venv" + venv.create(venv_dir, with_pip=True) + py = venv_dir / "bin" / "python" + pip = venv_dir / "bin" / "pip" + + subprocess.run( + [str(pip), "install", "--quiet", "python-frontmatter"], + check=True, + capture_output=True, + ) + subprocess.run( + [str(pip), "install", "--quiet", "--no-deps", "-e", str(REPO_ROOT)], + check=True, + capture_output=True, + ) + + base = subprocess.run( + [str(py), "-c", "import attune_help; print('ok')"], + capture_output=True, + text=True, + ) + assert base.returncode == 0, f"base import failed without authoring extra: {base.stderr}" + assert "ok" in base.stdout + + +@pytest.mark.slow +@pytest.mark.skipif( + sys.platform.startswith("win"), + reason="venv layout differs on Windows; covered on Linux/macOS", +) +@pytest.mark.parametrize( + "shim_module", + [ + "attune_help.manifest", + "attune_help.staleness", + "attune_help.freshness", + "attune_help.freshness.symbols", + ], +) +def test_shim_imports_fail_helpfully_without_authoring(tmp_path: Path, shim_module: str) -> None: + venv_dir = tmp_path / "venv" + venv.create(venv_dir, with_pip=True) + py = venv_dir / "bin" / "python" + pip = venv_dir / "bin" / "pip" + + subprocess.run( + [str(pip), "install", "--quiet", "python-frontmatter"], + check=True, + capture_output=True, + ) + subprocess.run( + [str(pip), "install", "--quiet", "--no-deps", "-e", str(REPO_ROOT)], + check=True, + capture_output=True, + ) + + result = subprocess.run( + [str(py), "-c", f"import {shim_module}"], + capture_output=True, + text=True, + ) + assert ( + result.returncode != 0 + ), f"{shim_module} imported without [authoring]: it should have raised" + assert "authoring" in result.stderr, ( + f"{shim_module} ImportError did not mention 'authoring' extra: " f"{result.stderr}" + )