diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bd8e743 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: ci + +# Continuous integration: run the unit test suite on every push to main and +# every pull request, so each commit carries a trustworthy pass/fail mark. +# Network-dependent integration tests are deselected (-m "not integration") +# to keep this signal stable — upstream rate-limiting or transient network +# errors must never turn a correct commit red. Run those locally with a +# bare `python -m pytest` when you need to exercise the live pipeline. +# +# Cross-platform coverage is handled by release.yml, which builds and smoke +# tests the PyInstaller binary on windows/ubuntu/macos per release tag. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install package and test deps + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest pytest-asyncio + + - name: Run unit tests (skip network integration tests) + run: python -m pytest -m "not integration" diff --git a/agent-web-search.spec b/agent-web-search.spec index 32e0d0d..6bd99ad 100644 --- a/agent-web-search.spec +++ b/agent-web-search.spec @@ -7,7 +7,7 @@ # Build: pyinstaller agent-web-search.spec --noconfirm # Verified working: ddgs search returns results through the bundled exe. -from PyInstaller.utils.hooks import collect_submodules +from PyInstaller.utils.hooks import collect_submodules, copy_metadata block_cipher = None @@ -35,6 +35,11 @@ hiddenimports += [ "sse_starlette", ] +# Bundle our own dist-info so importlib.metadata.version() resolves at runtime. +# __init__.py reads __version__ from package metadata; without this the frozen +# binary would hit PackageNotFoundError and silently fall back to "0.0.0+dev". +datas = copy_metadata("agent-web-search") + # Excluded — only things known-safe to drop. (mcp.cli sys.exits on import; # the rest are unused heavy stdlib. Do NOT exclude xml/email — pkg_resources # and plistlib depend on them.) @@ -48,7 +53,7 @@ a = Analysis( ["src/agent_web_search/__main__.py"], pathex=["src"], binaries=[], - datas=[], + datas=datas, hiddenimports=hiddenimports, hookspath=[], runtime_hooks=[], diff --git a/pyproject.toml b/pyproject.toml index f94603e..aa6713c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-web-search" -version = "0.1.0" +version = "0.2.1" description = "A free, unlimited, stable web-search MCP tool for coding agents" readme = "README.md" license = { text = "MIT" } @@ -32,3 +32,6 @@ dev = [ [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +markers = [ + "integration: tests that hit the real network (skipped in CI by default; run locally with a bare `python -m pytest`)", +] diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py index 0b0b287..f8afdc9 100644 --- a/scripts/smoke_test.py +++ b/scripts/smoke_test.py @@ -62,12 +62,28 @@ def main() -> int: return 1 resp = json.loads(line) - name = resp.get("result", {}).get("serverInfo", {}).get("name", "") + server_info = resp.get("result", {}).get("serverInfo", {}) + name = server_info.get("name", "") if "agent-web-search" not in name: print(f"FAIL: bad server name: {name}", file=sys.stderr) return 1 - print(f"smoke test OK: {time.monotonic() - t0:.2f}s startup, server={name}") + # Assert the version propagated. __init__.py reads it from package + # metadata via importlib.metadata; the spec bundles that metadata via + # copy_metadata(). If either link breaks, the binary silently falls + # back to "0.0.0+dev" — catch that here so a broken release never + # ships with a garbage version. + version = server_info.get("version", "") + if not version or version.startswith("0.0.0+dev"): + print( + f"FAIL: bad/missing server version: {version!r} " + "(expected a real release version — the binary likely fell back " + "to the dev placeholder, meaning package metadata isn't bundled)", + file=sys.stderr, + ) + return 1 + + print(f"smoke test OK: {time.monotonic() - t0:.2f}s startup, server={name} v{version}") return 0 finally: proc.terminate() diff --git a/src/agent_web_search/__init__.py b/src/agent_web_search/__init__.py index 53aabd2..7fca0d3 100644 --- a/src/agent_web_search/__init__.py +++ b/src/agent_web_search/__init__.py @@ -1,3 +1,15 @@ """agent-web-search: a free, unlimited, stable web-search MCP tool.""" -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + # Single source of truth: pyproject.toml's [project] version. Reading it + # via importlib.metadata means changing pyproject.toml is the only edit + # needed at release — __version__, the MCP serverInfo.version, and the + # PyInstaller-built binary all follow automatically once installed. + __version__ = version("agent-web-search") +except PackageNotFoundError: + # Not installed (e.g. running from a source checkout without `pip install + # -e .`). Fall back rather than crash — tests and dev workflows that don't + # care about the version still work. + __version__ = "0.0.0+dev" diff --git a/tests/test_mcp_stdio.py b/tests/test_mcp_stdio.py index 19b6d93..d53855d 100644 --- a/tests/test_mcp_stdio.py +++ b/tests/test_mcp_stdio.py @@ -127,7 +127,9 @@ def test_stdout_is_clean_json_rpc_only(server_env: dict[str, str]) -> None: import time time.sleep(0.3) - proc.stdin and proc.stdin.close() + # communicate() closes stdin itself; closing it manually first makes + # communicate()'s internal stdin.flush() raise "flush of closed file" + # on Linux (Windows tolerates it). Don't pre-close. out, _ = proc.communicate(timeout=5) except subprocess.TimeoutExpired: proc.kill() @@ -142,6 +144,7 @@ def test_stdout_is_clean_json_rpc_only(server_env: dict[str, str]) -> None: assert parsed >= 2, f"expected >=2 JSON-RPC responses, got {parsed}" +@pytest.mark.integration def test_tools_call_returns_json_shaped_response( server_env: dict[str, str] ) -> None: @@ -150,6 +153,11 @@ def test_tools_call_returns_json_shaped_response( We only assert the server returns a well-formed JSON-RPC response whose content is valid JSON (a results list or an error object), tolerant of live rate-limiting. Live results aren't guaranteed. + + Marked ``integration`` because it hits the live ddgs/DuckDuckGo pipeline + and is therefore weather-dependent. CI deselects it (``-m "not + integration"``); run it locally with a bare ``python -m pytest`` to + exercise the real network path. """ proc = _spawn(server_env) try: