Skip to content

Commit 4a5a1ce

Browse files
mcintclaude
andcommitted
Add CI/release workflows, snapshot tests, and pytest
- .github/workflows/ci.yml: test on Python 3.9/3.12/3.13 (macOS) - .github/workflows/release.yml: PyPI trusted publishing + GitHub release on tag - tests/snap.py: Jane Street expect-test style snapshot helper - tests/test_cli.py: 8 tests covering help, version, cache JSON, duration parsing/formatting, FTS scoring, query building, and .rb parser - UPDATE_SNAPSHOTS=1 to regenerate snapshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dab76ce commit 4a5a1ce

8 files changed

Lines changed: 455 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: macos-latest
11+
strategy:
12+
matrix:
13+
python-version: ["3.9", "3.12", "3.13"]
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: astral-sh/setup-uv@v6
17+
- name: Set up Python ${{ matrix.python-version }}
18+
run: uv python install ${{ matrix.python-version }}
19+
- name: Install
20+
run: uv sync --python ${{ matrix.python-version }}
21+
- name: Smoke test
22+
run: |
23+
uv run brew-hop-search --help
24+
uv run python -c "from brew_hop_search import __version__; print(__version__)"
25+
- name: Run tests
26+
run: uv run python -m pytest tests/ -v

.github/workflows/release.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
id-token: write
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: astral-sh/setup-uv@v6
18+
- name: Build
19+
run: uv build
20+
- name: Upload artifacts
21+
uses: actions/upload-artifact@v4
22+
with:
23+
name: dist
24+
path: dist/
25+
26+
pypi-publish:
27+
needs: build
28+
runs-on: ubuntu-latest
29+
environment:
30+
name: pypi
31+
url: https://pypi.org/project/brew-hop-search/
32+
permissions:
33+
id-token: write
34+
steps:
35+
- name: Download artifacts
36+
uses: actions/download-artifact@v4
37+
with:
38+
name: dist
39+
path: dist/
40+
- name: Publish to PyPI
41+
uses: pypa/gh-action-pypi-publish@release/v1
42+
43+
github-release:
44+
needs: build
45+
runs-on: ubuntu-latest
46+
permissions:
47+
contents: write
48+
steps:
49+
- uses: actions/checkout@v4
50+
- name: Download artifacts
51+
uses: actions/download-artifact@v4
52+
with:
53+
name: dist
54+
path: dist/
55+
- name: Create GitHub Release
56+
env:
57+
GH_TOKEN: ${{ github.token }}
58+
run: |
59+
gh release create ${{ github.ref_name }} dist/* \
60+
--title "${{ github.ref_name }}" \
61+
--generate-notes

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ classifiers = [
2222
]
2323
dependencies = ["sqlite-utils"]
2424

25+
[dependency-groups]
26+
dev = ["pytest"]
27+
2528
[project.scripts]
2629
brew-hop-search = "brew_hop_search.cli:main"
2730

tests/__init__.py

Whitespace-only changes.

tests/snap.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Snapshot testing helper — Jane Street expect-test style.
2+
3+
Usage in tests:
4+
from tests.snap import snap
5+
6+
def test_help(snap):
7+
result = subprocess.run(["brew-hop-search", "--help"], capture_output=True, text=True)
8+
snap.assert_match(result.stdout)
9+
10+
On first run (or with UPDATE_SNAPSHOTS=1), writes the snapshot file.
11+
On subsequent runs, diffs against stored snapshot.
12+
To update: UPDATE_SNAPSHOTS=1 pytest tests/
13+
"""
14+
from __future__ import annotations
15+
16+
import os
17+
import textwrap
18+
from pathlib import Path
19+
20+
import pytest
21+
22+
SNAP_DIR = Path(__file__).parent / "snapshots"
23+
UPDATE = os.environ.get("UPDATE_SNAPSHOTS", "") == "1"
24+
25+
26+
class Snap:
27+
def __init__(self, name: str):
28+
self.path = SNAP_DIR / f"{name}.txt"
29+
self._call_count = 0
30+
31+
def assert_match(self, actual: str, suffix: str = "") -> None:
32+
self._call_count += 1
33+
snap_path = self.path.with_suffix(f".{suffix}.txt") if suffix else self.path
34+
if self._call_count > 1 and not suffix:
35+
snap_path = self.path.with_suffix(f".{self._call_count}.txt")
36+
37+
actual = actual.rstrip("\n") + "\n"
38+
39+
if UPDATE or not snap_path.exists():
40+
SNAP_DIR.mkdir(parents=True, exist_ok=True)
41+
snap_path.write_text(actual)
42+
return
43+
44+
expected = snap_path.read_text()
45+
if actual != expected:
46+
# Show unified diff
47+
import difflib
48+
diff = difflib.unified_diff(
49+
expected.splitlines(keepends=True),
50+
actual.splitlines(keepends=True),
51+
fromfile=f"expected ({snap_path.name})",
52+
tofile="actual",
53+
)
54+
diff_str = "".join(diff)
55+
pytest.fail(
56+
f"Snapshot mismatch for {snap_path.name}.\n"
57+
f"Run with UPDATE_SNAPSHOTS=1 to update.\n\n{diff_str}"
58+
)
59+
60+
61+
@pytest.fixture
62+
def snap(request) -> Snap:
63+
"""Pytest fixture — snapshot name derived from test function name."""
64+
return Snap(request.node.name)

tests/snapshots/test_help.txt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
usage: brew-hop-search [-h] [--refresh] [-f] [-c] [-i] [-t] [-L] [-C]
2+
[--stale DUR] [--fresh DUR] [-n N] [--json] [-g]
3+
[query]
4+
5+
Fast offline-first Homebrew formula/cask search.
6+
7+
positional arguments:
8+
query Search query
9+
10+
options:
11+
-h, --help show this help message and exit
12+
--refresh Force synchronous re-fetch before searching
13+
-f, --formulae, --formula
14+
Search formulae only
15+
-c, --casks, --cask Search casks only
16+
-i, --installed Search only installed packages
17+
-t, --taps Also search formulae/casks from tapped repos
18+
-L, --local Search brew's local API cache (offline)
19+
-C, --cache Show cache status and exit
20+
--stale DUR Background refresh if cache older than DUR (default:
21+
6h)
22+
--fresh DUR Force synchronous refresh if cache older than DUR
23+
-n N, --limit N Max results per section (default 20)
24+
--json Output raw JSON
25+
-g, --grep Greppable output: slug\tversion\turl\n description

tests/test_cli.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Snapshot tests for brew-hop-search CLI output."""
2+
from __future__ import annotations
3+
4+
import re
5+
import subprocess
6+
import sys
7+
8+
import pytest
9+
10+
from tests.snap import snap # noqa: F401 (fixture)
11+
12+
13+
def run(*args: str) -> str:
14+
"""Run brew-hop-search and return stdout, stripping ANSI codes."""
15+
result = subprocess.run(
16+
[sys.executable, "-m", "brew_hop_search.cli", *args],
17+
capture_output=True, text=True, timeout=30,
18+
)
19+
# Strip ANSI escape codes for stable snapshots
20+
clean = re.sub(r"\033\[[0-9;]*m", "", result.stdout + result.stderr)
21+
return clean
22+
23+
24+
def test_help(snap):
25+
snap.assert_match(run("--help"))
26+
27+
28+
def test_version_importable():
29+
from brew_hop_search import __version__
30+
assert re.match(r"\d+\.\d+\.\d+", __version__)
31+
32+
33+
def test_cache_status_json():
34+
"""Cache status JSON should be valid and have expected keys."""
35+
import json
36+
output = run("-C", "--json")
37+
data = json.loads(output)
38+
assert "cache_dir" in data
39+
assert "db_path" in data
40+
assert "sources" in data
41+
42+
43+
def test_duration_parsing():
44+
from brew_hop_search.cli import parse_duration
45+
assert parse_duration("30m") == 1800
46+
assert parse_duration("6h") == 21600
47+
assert parse_duration("1d") == 86400
48+
assert parse_duration("1h30m") == 5400
49+
assert parse_duration("90") == 90
50+
51+
52+
def test_duration_formatting():
53+
from brew_hop_search.display import fmt_duration
54+
assert fmt_duration(30) == "30s"
55+
assert fmt_duration(90) == "1m30s"
56+
assert fmt_duration(3600) == "1h"
57+
assert fmt_duration(3660) == "1h1m"
58+
assert fmt_duration(86400) == "1d"
59+
assert fmt_duration(float("inf")) == "never"
60+
61+
62+
def test_scoring():
63+
from brew_hop_search.search import score
64+
# Exact match
65+
assert score("python", "", ["python"]) == 100
66+
# Prefix match
67+
assert score("python3", "", ["python"]) == 60
68+
# Substring match
69+
assert score("cpython", "", ["python"]) == 30
70+
# Description match
71+
assert score("foo", "uses python", ["python"]) == 10
72+
# No match
73+
assert score("foo", "bar", ["python"]) == 0
74+
# All terms must match
75+
assert score("python", "language", ["python", "java"]) == 0
76+
77+
78+
def test_fts_query():
79+
from brew_hop_search.search import fts_query
80+
assert fts_query(["python"]) == '"python"*'
81+
assert fts_query(["python", "build"]) == '"python"* AND "build"*'
82+
83+
84+
def test_rb_parser():
85+
"""Test the lightweight Ruby formula parser."""
86+
import tempfile
87+
from pathlib import Path
88+
from brew_hop_search.sources.taps import parse_rb
89+
90+
rb_content = '''
91+
cask "test-app" do
92+
version "1.2.3"
93+
desc "A test application"
94+
homepage "https://example.com"
95+
url "https://example.com/download/v1.2.3/test.dmg"
96+
end
97+
'''
98+
with tempfile.NamedTemporaryFile(suffix=".rb", mode="w", delete=False) as f:
99+
f.write(rb_content)
100+
f.flush()
101+
result = parse_rb(Path(f.name), "test/tap")
102+
103+
assert result is not None
104+
assert result["name"] == Path(f.name).stem
105+
assert result["version"] == "1.2.3"
106+
assert result["desc"] == "A test application"
107+
assert result["homepage"] == "https://example.com"
108+
assert result["tap"] == "test/tap"

0 commit comments

Comments
 (0)