157	def materialize_compass_listing(
158	    parsed: CompassListingParse,
159	    *,
160	    media_root: str | Path,
161	    http_get: HttpGet | None = None,
162	    max_photos: int = 20,
163	) -> ListingSnapshot:
164	    """Download photo URLs into media_root and return a ListingSnapshot."""
165	    root = Path(media_root)
166	    photos_dir = root / "photos"
167	    photos_dir.mkdir(parents=True, exist_ok=True)
168	    getter = http_get or _default_http_get
169	    photos: list[ListingPhoto] = []
170	    for index, photo_url in enumerate(parsed.photo_urls[: max(1, max_photos)]):
171	        suffix = _extension_for_url(photo_url)
172	        relative = Path("photos") / f"photo_{index + 1:02d}{suffix}"
173	        dest = root / relative
174	        try:
175	            payload = getter(photo_url)
176	        except CompassListingError:
177	            raise
178	        except Exception as exc:  # pragma: no cover - network seams cover this with mocked get
179	            raise CompassListingError(f"Failed to download Compass photo {index + 1}: {exc}") from exc
180	        if not payload:
181	            raise CompassListingError(f"Compass photo {index + 1} was empty")
182	        dest.write_bytes(payload)
183	        digest = hashlib.sha256(payload).hexdigest()
184	        photos.append(
185	            ListingPhoto(
186	                path=dest.resolve(),
187	                relative_path=relative.as_posix(),
188	                sha256=digest,
189	                size_bytes=len(payload),
190	                label=f"Photo {index + 1}",
191	                focal_point=(0.5, 0.5),
192	            )
193	        )
194
195	    if not photos:
196	        raise CompassListingError("No Compass photos were downloaded")
197
198	    return ListingSnapshot(
199	        source_url=parsed.source_url,
200	        source_provider="compass_public_demo",
201	        rights_confirmed=True,
202	        attribution=parsed.attribution,
203	        address=parsed.address,
204	        city=parsed.city,
205	        state=parsed.state,
206	        postal_code=parsed.postal_code,
207	        price=parsed.price,
208	        beds=parsed.beds,
209	        baths=parsed.baths,
210	        square_feet=parsed.square_feet,
211	        description=parsed.description,
212	        photos=tuple(photos),
213	        cover_photo_index=0,
214	        created_at=datetime.now(UTC).isoformat(),
215	    )
216

def materialize_compass_listing(
    parsed: CompassListingParse,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
) -> ListingSnapshot:
    """Download photo URLs into media_root and return a ListingSnapshot."""
    root = Path(media_root)
    photos_dir = root / "photos"
    photos_dir.mkdir(parents=True, exist_ok=True)
    getter = http_get or _default_http_get
    photos: list[ListingPhoto] = []
    for index, photo_url in enumerate(parsed.photo_urls[: max(1, max_photos)]):
        suffix = _extension_for_url(photo_url)
        relative = Path("photos") / f"photo_{index + 1:02d}{suffix}"
        dest = root / relative
        try:
            payload = getter(photo_url)
        except CompassListingError:
            raise
        except Exception as exc:  # pragma: no cover - network seams cover this with mocked get
            raise CompassListingError(f"Failed to download Compass photo {index + 1}: {exc}") from exc
        if not payload:
            raise CompassListingError(f"Compass photo {index + 1} was empty")
        dest.write_bytes(payload)
        digest = hashlib.sha256(payload).hexdigest()
        photos.append(
            ListingPhoto(
                path=dest.resolve(),
                relative_path=relative.as_posix(),
                sha256=digest,
                size_bytes=len(payload),
                label=f"Photo {index + 1}",
                focal_point=(0.5, 0.5),
            )
        )

    if not photos:
        raise CompassListingError("No Compass photos were downloaded")

    return ListingSnapshot(
        source_url=parsed.source_url,
        source_provider="compass_public_demo",
        rights_confirmed=True,
        attribution=parsed.attribution,
        address=parsed.address,
        city=parsed.city,
        state=parsed.state,
        postal_code=parsed.postal_code,
        price=parsed.price,
        beds=parsed.beds,
        baths=parsed.baths,
        square_feet=parsed.square_feet,
        description=parsed.description,
        photos=tuple(photos),
        cover_photo_index=0,
        created_at=datetime.now(UTC).isoformat(),
    )


1	from __future__ import annotations
2
3	import tempfile
4	import unittest
5	from pathlib import Path
6	from unittest.mock import MagicMock
7
8	from core.listings.manifest import ListingManifestError, SourcePolicy, load_listing_source, source_policy_for
9	from core.listings.providers.compass import (
10	    CompassListingError,
11	    fetch_compass_listing,
12	    is_compass_listing_url,
13	    parse_compass_listing_html,
14	)
15	from core.listings.providers.registry import build_authorized_fetcher
16
17	FIXTURE_HTML = Path(__file__).parent / "fixtures" / "compass" / "sample_listing.html"
18	COMPASS_URL = "https://www.compass.com/homedetails/1002-Horne-Dr-Cedar-Park-TX-78613/1877020858229275065_lid/"
19	PHOTO_A = "https://www.compass.com/m/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/origin.jpg"
20	PHOTO_B = "https://www.compass.com/m/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/origin.jpg"
21
22
23	class CompassProviderTests(unittest.TestCase):
24	    def test_source_policy_allows_compass_adapter_path(self) -> None:
25	        self.assertTrue(is_compass_listing_url(COMPASS_URL))
26	        self.assertEqual(source_policy_for(COMPASS_URL), SourcePolicy.AUTHORIZED_ADAPTER)
27
28	    def test_parse_fixture_html_extracts_facts_and_deduped_photos(self) -> None:
29	        html = FIXTURE_HTML.read_text(encoding="utf-8")
30	        parsed = parse_compass_listing_html(html, listing_url=COMPASS_URL)
31
32	        self.assertEqual(parsed.address, "1002 Horne Dr")
33	        self.assertEqual(parsed.city, "Cedar Park")
34	        self.assertEqual(parsed.state, "TX")
35	        self.assertEqual(parsed.postal_code, "78613")
36	        self.assertEqual(parsed.price, 692500)
37	        self.assertEqual(parsed.beds, 5)
38	        self.assertEqual(parsed.baths, 3)
39	        self.assertEqual(parsed.square_feet, 2684)
40	        self.assertIn("Cypress Canyon", parsed.description)
41	        self.assertEqual(parsed.agent_name, "Richard Fowler")
42	        self.assertEqual(parsed.photo_urls, (PHOTO_A, PHOTO_B))
43	        self.assertIn("pilot review only", parsed.attribution)
44
45	    def test_fetch_materializes_photos_and_manifest(self) -> None:
46	        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
47	        photo_bytes = {
48	            PHOTO_A: b"P3\n1 1\n255\n220 210 190\n",
49	            PHOTO_B: b"P3\n1 1\n255\n10 20 30\n",
50	        }
51
52	        def http_get(url: str) -> bytes:
53	            if url.rstrip("/") == COMPASS_URL.rstrip("/") or url.startswith(COMPASS_URL):
54	                return html
55	            if url in photo_bytes:
56	                return photo_bytes[url]
57	            # origin rewrite variants for the second media hash may be requested as origin.jpg
58	            if url.endswith("/origin.jpg"):
59	                key = url
60	                for candidate, payload in photo_bytes.items():
61	                    if candidate.split("/")[-2] in url:
62	                        return payload
63	            raise AssertionError(f"unexpected URL {url}")
64
65	        with tempfile.TemporaryDirectory() as tmp:
66	            root = Path(tmp)
67	            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get)
68
69	            self.assertEqual(snapshot.source_provider, "compass_public_demo")
70	            self.assertTrue(snapshot.rights_confirmed)
71	            self.assertEqual(snapshot.address, "1002 Horne Dr")
72	            self.assertEqual(len(snapshot.photos), 2)
73	            self.assertTrue((root / "photos" / "photo_01.jpg").exists())
74	            self.assertTrue((root / "photos" / "photo_02.jpg").exists())
75	            self.assertTrue((root / "listing.json").exists())
76	            self.assertEqual(len(snapshot.photos[0].sha256), 64)
77
78	    def test_registry_requires_demo_or_rights_flag(self) -> None:
79	        with tempfile.TemporaryDirectory() as tmp:
80	            root = Path(tmp)

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock

from core.listings.manifest import ListingManifestError, SourcePolicy, load_listing_source, source_policy_for
from core.listings.providers.compass import (
    CompassListingError,
    fetch_compass_listing,
    is_compass_listing_url,
    parse_compass_listing_html,
)
from core.listings.providers.registry import build_authorized_fetcher

FIXTURE_HTML = Path(__file__).parent / "fixtures" / "compass" / "sample_listing.html"
COMPASS_URL = "https://www.compass.com/homedetails/1002-Horne-Dr-Cedar-Park-TX-78613/1877020858229275065_lid/"
PHOTO_A = "https://www.compass.com/m/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/origin.jpg"
PHOTO_B = "https://www.compass.com/m/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/origin.jpg"

class CompassProviderTests(unittest.TestCase):
    def test_source_policy_allows_compass_adapter_path(self) -> None:
        self.assertTrue(is_compass_listing_url(COMPASS_URL))
        self.assertEqual(source_policy_for(COMPASS_URL), SourcePolicy.AUTHORIZED_ADAPTER)

    def test_parse_fixture_html_extracts_facts_and_deduped_photos(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8")
        parsed = parse_compass_listing_html(html, listing_url=COMPASS_URL)

        self.assertEqual(parsed.address, "1002 Horne Dr")
        self.assertEqual(parsed.city, "Cedar Park")
        self.assertEqual(parsed.state, "TX")
        self.assertEqual(parsed.postal_code, "78613")
        self.assertEqual(parsed.price, 692500)
        self.assertEqual(parsed.beds, 5)
        self.assertEqual(parsed.baths, 3)
        self.assertEqual(parsed.square_feet, 2684)
        self.assertIn("Cypress Canyon", parsed.description)
        self.assertEqual(parsed.agent_name, "Richard Fowler")
        self.assertEqual(parsed.photo_urls, (PHOTO_A, PHOTO_B))
        self.assertIn("pilot review only", parsed.attribution)

    def test_fetch_materializes_photos_and_manifest(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo_bytes = {
            PHOTO_A: b"P3\n1 1\n255\n220 210 190\n",
            PHOTO_B: b"P3\n1 1\n255\n10 20 30\n",
        }

        def http_get(url: str) -> bytes:
            if url.rstrip("/") == COMPASS_URL.rstrip("/") or url.startswith(COMPASS_URL):
                return html
            if url in photo_bytes:
                return photo_bytes[url]
            # origin rewrite variants for the second media hash may be requested as origin.jpg
            if url.endswith("/origin.jpg"):
                key = url
                for candidate, payload in photo_bytes.items():
                    if candidate.split("/")[-2] in url:
                        return payload
            raise AssertionError(f"unexpected URL {url}")

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get)

            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertTrue(snapshot.rights_confirmed)
            self.assertEqual(snapshot.address, "1002 Horne Dr")
            self.assertEqual(len(snapshot.photos), 2)
            self.assertTrue((root / "photos" / "photo_01.jpg").exists())
            self.assertTrue((root / "photos" / "photo_02.jpg").exists())
            self.assertTrue((root / "listing.json").exists())
            self.assertEqual(len(snapshot.photos[0].sha256), 64)

    def test_registry_requires_demo_or_rights_flag(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)


Ingest Claude blog post on marketing operations automation


80	            root = Path(tmp)
81	            blocked = build_authorized_fetcher(media_root=root, demo=False, rights_confirmed=False)
82	            with self.assertRaisesRegex(ListingManifestError, "demo-only"):
83	                blocked(COMPASS_URL)
84
85	    def test_registry_uses_compass_adapter_when_demo_enabled(self) -> None:
86	        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
87	        photo = b"P3\n1 1\n255\n1 2 3\n"
88
89	        def http_get(url: str) -> bytes:
90	            if "homedetails" in url:
91	                return html
92	            return photo
93
94	        with tempfile.TemporaryDirectory() as tmp:
95	            root = Path(tmp)
96	            fetcher = build_authorized_fetcher(
97	                media_root=root,
98	                demo=True,
99	                rights_confirmed=False,
100	                http_get=http_get,
101	            )
102	            snapshot = load_listing_source(COMPASS_URL, fetcher=fetcher)
103	            self.assertEqual(snapshot.source_provider, "compass_public_demo")
104	            self.assertEqual(snapshot.city, "Cedar Park")
105	            self.assertGreaterEqual(len(snapshot.photos), 1)
106
107	    def test_zillow_still_never_calls_fetcher(self) -> None:
108	        fetcher = MagicMock()
109	        zillow = "https://www.zillow.com/homedetails/example/1_zpid/"
110	        self.assertEqual(source_policy_for(zillow), SourcePolicy.MANIFEST_ONLY)
111	        with self.assertRaisesRegex(ListingManifestError, "authorized manifest"):
112	            load_listing_source(zillow, fetcher=fetcher)
113	        fetcher.assert_not_called()
114
115	    def test_parse_rejects_empty_html(self) -> None:
116	        with self.assertRaisesRegex(CompassListingError, "empty"):
117	            parse_compass_listing_html("   ", listing_url=COMPASS_URL)
118
119
120	if __name__ == "__main__":
121	    unittest.main()
122

            root = Path(tmp)
            blocked = build_authorized_fetcher(media_root=root, demo=False, rights_confirmed=False)
            with self.assertRaisesRegex(ListingManifestError, "demo-only"):
                blocked(COMPASS_URL)

    def test_registry_uses_compass_adapter_when_demo_enabled(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo = b"P3\n1 1\n255\n1 2 3\n"

        def http_get(url: str) -> bytes:
            if "homedetails" in url:
                return html
            return photo

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
            )
            snapshot = load_listing_source(COMPASS_URL, fetcher=fetcher)
            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertEqual(snapshot.city, "Cedar Park")
            self.assertGreaterEqual(len(snapshot.photos), 1)

    def test_zillow_still_never_calls_fetcher(self) -> None:
        fetcher = MagicMock()
        zillow = "https://www.zillow.com/homedetails/example/1_zpid/"
        self.assertEqual(source_policy_for(zillow), SourcePolicy.MANIFEST_ONLY)
        with self.assertRaisesRegex(ListingManifestError, "authorized manifest"):
            load_listing_source(zillow, fetcher=fetcher)
        fetcher.assert_not_called()

    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

if __name__ == "__main__":
    unittest.main()


Now I have a clear picture. I need to:

1. Add a `min_photos` parameter to `materialize_compass_listing` (default 5)
2. After downloading, check if `len(photos) < min_photos` and raise `CompassListingError` if so
3. Write a RED test first, then make it GREEN

Looking at the current code:
- `materialize_compass_listing` has `max_photos: int = 20` parameter
- After the download loop, it checks `if not photos: raise CompassListingError("No Compass photos were downloaded")`
- I'll add the `min_photos` parameter alongside `max_photos` and add a validation check after the download completes
- The test should mock a scenario with fewer photos than the minimum threshold and verify the error is raised with an appropriate message

Let me check the full compass.py file to see the import structure and determine the exact placement for these changes.


1	"""Compass allowlisted demo adapter.
2
3	Demo-grade intake for Richard Fowler compass.com listing pages.
4
5	This is intentionally *not* a production rights path. Production still needs
6	brokerage / photographer / MLS authorization (or a licensed feed). The adapter
7	records demo provenance so human review never confuses pilot media with a
8	shipped rights-cleared source.
9	"""
10
11	from __future__ import annotations
12
13	import hashlib
14	import json
15	import re
16	from dataclasses import dataclass
17	from datetime import UTC, datetime
18	from pathlib import Path
19	from typing import Any, Callable, Mapping, Sequence
20	from urllib.error import HTTPError, URLError
21	from urllib.parse import urlsplit
22	from urllib.request import Request, urlopen
23
24	from core.listings.manifest import ListingManifestError, normalize_listing_url
25	from core.listings.models import ListingPhoto, ListingSnapshot
26
27	COMPASS_HOSTS = frozenset({"compass.com", "www.compass.com"})
28	DEFAULT_USER_AGENT = (
29	    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
30	    "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"

"""Compass allowlisted demo adapter.

Demo-grade intake for Richard Fowler compass.com listing pages.

This is intentionally *not* a production rights path. Production still needs
brokerage / photographer / MLS authorization (or a licensed feed). The adapter
records demo provenance so human review never confuses pilot media with a
shipped rights-cleared source.
"""

from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen

from core.listings.manifest import ListingManifestError, normalize_listing_url
from core.listings.models import ListingPhoto, ListingSnapshot

COMPASS_HOSTS = frozenset({"compass.com", "www.compass.com"})
DEFAULT_USER_AGENT = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
    "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"


Now I have everything I need. Let me implement the fix:

1. Add `min_photos: int = 5` parameter to `materialize_compass_listing`
2. After the download loop and `if not photos` check, add:
   ```python
   if len(photos) < min_photos:
       raise CompassListingError(
           f"Compass demo requires at least {min_photos} photos for a usable reel; "
           f"only {len(photos)} downloaded. Expand the gallery or lower min_photos."
       )
   ```

3.

I should follow TDD here—write the test first to verify it fails, then implement the fix. I'll add a test to `tests/test_compass_provider.py` that creates a listing with only 2 photos, mocks the HTTP responses, and calls `materialize_compass_listing` with the default `min_photos` value to confirm it raises the expected error.

I also need a success case test to verify the behavior when the photo count meets or exceeds the threshold. Before I write the tests, let me check the structure of `CompassListingParse` so I can construct valid test instances properly.


Bash

grep -n "class CompassListingParse\|@dataclass" /workspace/extra/projects/stagehand/core/listings/providers/compass.py | head -10

Find CompassListingParse dataclass definition


44:@dataclass(frozen=True, slots=True)
45:class CompassListingParse:


44	@dataclass(frozen=True, slots=True)
45	class CompassListingParse:
46	    source_url: str
47	    address: str
48	    city: str
49	    state: str
50	    postal_code: str
51	    price: int | None
52	    beds: float | None
53	    baths: float | None
54	    square_feet: int | None
55	    description: str
56	    photo_urls: tuple[str, ...]
57	    attribution: str
58	    agent_name: str = ""
59
60
61	HttpGet = Callable[[str], bytes]
62
63
64	def is_compass_listing_url(listing_url: str) -> bool:
65	    try:
66	        host = (urlsplit(normalize_listing_url(listing_url)).hostname or "").lower()
67	    except ListingManifestError:
68	        return False
69	    return host in COMPASS_HOSTS
70
71
72	def parse_compass_listing_html(html: str, *, listing_url: str) -> CompassListingParse:
73	    if not html or not html.strip():

@dataclass(frozen=True, slots=True)
class CompassListingParse:
    source_url: str
    address: str
    city: str
    state: str
    postal_code: str
    price: int | None
    beds: float | None
    baths: float | None
    square_feet: int | None
    description: str
    photo_urls: tuple[str, ...]
    attribution: str
    agent_name: str = ""

HttpGet = Callable[[str], bytes]

def is_compass_listing_url(listing_url: str) -> bool:
    try:
        host = (urlsplit(normalize_listing_url(listing_url)).hostname or "").lower()
    except ListingManifestError:
        return False
    return host in COMPASS_HOSTS

def parse_compass_listing_html(html: str, *, listing_url: str) -> CompassListingParse:
    if not html or not html.strip():


Now I have everything. Let me implement the changes:

1. First add a RED test for `min_photos`
2. Run to confirm RED
3. Add the code change
4. Run to confirm GREEN

The test should:
- Create a `CompassListingParse` with 2 photos (below default min of 5)
- Call `materialize_compass_listing` with default `min_photos=5`
- Assert `CompassListingError` is raised

And also:
- A success test where `min_photos=2` and 2 photos are provided

Let me write the test.


Now I'll write the RED test, implement the fix, then verify GREEN:


Edit

from core.listings.providers.compass import (
    CompassListingError,
    fetch_compass_listing,
    is_compass_listing_url,
    parse_compass_listing_html,
)

from core.listings.providers.compass import (
    CompassListingError,
    CompassListingParse,
    fetch_compass_listing,
    is_compass_listing_url,
    materialize_compass_listing,
    parse_compass_listing_html,
)


The file /workspace/extra/projects/stagehand/tests/test_compass_provider.py has been updated successfully. (file state is current in your context — no need to Read it back)

from core.listings.providers.compass import (
    CompassListingError,
    CompassListingParse,
    fetch_compass_listing,
    is_compass_listing_url,
    materialize_compass_listing,
    parse_compass_listing_html,
)

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock

from core.listings.manifest import ListingManifestError, SourcePolicy, load_listing_source, source_policy_for
from core.listings.providers.compass import (
    CompassListingError,
    fetch_compass_listing,
    is_compass_listing_url,
    parse_compass_listing_html,
)
from core.listings.providers.registry import build_authorized_fetcher

FIXTURE_HTML = Path(__file__).parent / "fixtures" / "compass" / "sample_listing.html"
COMPASS_URL = "https://www.compass.com/homedetails/1002-Horne-Dr-Cedar-Park-TX-78613/1877020858229275065_lid/"
PHOTO_A = "https://www.compass.com/m/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/origin.jpg"
PHOTO_B = "https://www.compass.com/m/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/origin.jpg"

class CompassProviderTests(unittest.TestCase):
    def test_source_policy_allows_compass_adapter_path(self) -> None:
        self.assertTrue(is_compass_listing_url(COMPASS_URL))
        self.assertEqual(source_policy_for(COMPASS_URL), SourcePolicy.AUTHORIZED_ADAPTER)

    def test_parse_fixture_html_extracts_facts_and_deduped_photos(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8")
        parsed = parse_compass_listing_html(html, listing_url=COMPASS_URL)

        self.assertEqual(parsed.address, "1002 Horne Dr")
        self.assertEqual(parsed.city, "Cedar Park")
        self.assertEqual(parsed.state, "TX")
        self.assertEqual(parsed.postal_code, "78613")
        self.assertEqual(parsed.price, 692500)
        self.assertEqual(parsed.beds, 5)
        self.assertEqual(parsed.baths, 3)
        self.assertEqual(parsed.square_feet, 2684)
        self.assertIn("Cypress Canyon", parsed.description)
        self.assertEqual(parsed.agent_name, "Richard Fowler")
        self.assertEqual(parsed.photo_urls, (PHOTO_A, PHOTO_B))
        self.assertIn("pilot review only", parsed.attribution)

    def test_fetch_materializes_photos_and_manifest(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo_bytes = {
            PHOTO_A: b"P3\n1 1\n255\n220 210 190\n",
            PHOTO_B: b"P3\n1 1\n255\n10 20 30\n",
        }

        def http_get(url: str) -> bytes:
            if url.rstrip("/") == COMPASS_URL.rstrip("/") or url.startswith(COMPASS_URL):
                return html
            if url in photo_bytes:
                return photo_bytes[url]
            # origin rewrite variants for the second media hash may be requested as origin.jpg
            if url.endswith("/origin.jpg"):
                key = url
                for candidate, payload in photo_bytes.items():
                    if candidate.split("/")[-2] in url:
                        return payload
            raise AssertionError(f"unexpected URL {url}")

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get)

            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertTrue(snapshot.rights_confirmed)
            self.assertEqual(snapshot.address, "1002 Horne Dr")
            self.assertEqual(len(snapshot.photos), 2)
            self.assertTrue((root / "photos" / "photo_01.jpg").exists())
            self.assertTrue((root / "photos" / "photo_02.jpg").exists())
            self.assertTrue((root / "listing.json").exists())
            self.assertEqual(len(snapshot.photos[0].sha256), 64)

    def test_registry_requires_demo_or_rights_flag(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            blocked = build_authorized_fetcher(media_root=root, demo=False, rights_confirmed=False)
            with self.assertRaisesRegex(ListingManifestError, "demo-only"):
                blocked(COMPASS_URL)

    def test_registry_uses_compass_adapter_when_demo_enabled(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo = b"P3\n1 1\n255\n1 2 3\n"

        def http_get(url: str) -> bytes:
            if "homedetails" in url:
                return html
            return photo

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
            )
            snapshot = load_listing_source(COMPASS_URL, fetcher=fetcher)
            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertEqual(snapshot.city, "Cedar Park")
            self.assertGreaterEqual(len(snapshot.photos), 1)

    def test_zillow_still_never_calls_fetcher(self) -> None:
        fetcher = MagicMock()
        zillow = "https://www.zillow.com/homedetails/example/1_zpid/"
        self.assertEqual(source_policy_for(zillow), SourcePolicy.MANIFEST_ONLY)
        with self.assertRaisesRegex(ListingManifestError, "authorized manifest"):
            load_listing_source(zillow, fetcher=fetcher)
        fetcher.assert_not_called()

    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

if __name__ == "__main__":
    unittest.main()


    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

if __name__ == "__main__":
    unittest.main()

    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

    # --- min_photos guard ---

    def _make_parsed(self, photo_urls: tuple[str, ...]) -> CompassListingParse:
        return CompassListingParse(
            source_url=COMPASS_URL,
            address="1002 Horne Dr",
            city="Cedar Park",
            state="TX",
            postal_code="78613",
            price=692500,
            beds=5,
            baths=3,
            square_feet=2684,
            description="Demo listing.",
            photo_urls=photo_urls,
            attribution="Demo intake.",
        )

    def test_materialize_rejects_below_min_photos(self) -> None:
        """Fail closed: fewer than min_photos downloaded → CompassListingError."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                    min_photos=5,
                )

    def test_materialize_accepts_at_min_photo_threshold(self) -> None:
        """Exactly min_photos downloaded → succeeds."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 62}{i:02d}/origin.jpg" for i in range(5)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            snapshot = materialize_compass_listing(
                parsed,
                media_root=tmp,
                http_get=lambda url: dummy,
                min_photos=5,
            )
        self.assertEqual(len(snapshot.photos), 5)

    def test_materialize_default_min_photos_is_five(self) -> None:
        """Default min_photos=5; 2 photos raises without explicit kwarg."""
        photos = tuple(
            f"https://www.compass.com/m/{'b' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n5 6 7\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                )

if __name__ == "__main__":
    unittest.main()


    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

    # --- min_photos guard ---

    def _make_parsed(self, photo_urls: tuple[str, ...]) -> CompassListingParse:
        return CompassListingParse(
            source_url=COMPASS_URL,
            address="1002 Horne Dr",
            city="Cedar Park",
            state="TX",
            postal_code="78613",
            price=692500,
            beds=5,
            baths=3,
            square_feet=2684,
            description="Demo listing.",
            photo_urls=photo_urls,
            attribution="Demo intake.",
        )

    def test_materialize_rejects_below_min_photos(self) -> None:
        """Fail closed: fewer than min_photos downloaded → CompassListingError."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                    min_photos=5,
                )

    def test_materialize_accepts_at_min_photo_threshold(self) -> None:
        """Exactly min_photos downloaded → succeeds."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 62}{i:02d}/origin.jpg" for i in range(5)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            snapshot = materialize_compass_listing(
                parsed,
                media_root=tmp,
                http_get=lambda url: dummy,
                min_photos=5,
            )
        self.assertEqual(len(snapshot.photos), 5)

    def test_materialize_default_min_photos_is_five(self) -> None:
        """Default min_photos=5; 2 photos raises without explicit kwarg."""
        photos = tuple(
            f"https://www.compass.com/m/{'b' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n5 6 7\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                )

if __name__ == "__main__":
    unittest.main()

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock

from core.listings.manifest import ListingManifestError, SourcePolicy, load_listing_source, source_policy_for
from core.listings.providers.compass import (
    CompassListingError,
    CompassListingParse,
    fetch_compass_listing,
    is_compass_listing_url,
    materialize_compass_listing,
    parse_compass_listing_html,
)
from core.listings.providers.registry import build_authorized_fetcher

FIXTURE_HTML = Path(__file__).parent / "fixtures" / "compass" / "sample_listing.html"
COMPASS_URL = "https://www.compass.com/homedetails/1002-Horne-Dr-Cedar-Park-TX-78613/1877020858229275065_lid/"
PHOTO_A = "https://www.compass.com/m/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/origin.jpg"
PHOTO_B = "https://www.compass.com/m/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/origin.jpg"

class CompassProviderTests(unittest.TestCase):
    def test_source_policy_allows_compass_adapter_path(self) -> None:
        self.assertTrue(is_compass_listing_url(COMPASS_URL))
        self.assertEqual(source_policy_for(COMPASS_URL), SourcePolicy.AUTHORIZED_ADAPTER)

    def test_parse_fixture_html_extracts_facts_and_deduped_photos(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8")
        parsed = parse_compass_listing_html(html, listing_url=COMPASS_URL)

        self.assertEqual(parsed.address, "1002 Horne Dr")
        self.assertEqual(parsed.city, "Cedar Park")
        self.assertEqual(parsed.state, "TX")
        self.assertEqual(parsed.postal_code, "78613")
        self.assertEqual(parsed.price, 692500)
        self.assertEqual(parsed.beds, 5)
        self.assertEqual(parsed.baths, 3)
        self.assertEqual(parsed.square_feet, 2684)
        self.assertIn("Cypress Canyon", parsed.description)
        self.assertEqual(parsed.agent_name, "Richard Fowler")
        self.assertEqual(parsed.photo_urls, (PHOTO_A, PHOTO_B))
        self.assertIn("pilot review only", parsed.attribution)

    def test_fetch_materializes_photos_and_manifest(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo_bytes = {
            PHOTO_A: b"P3\n1 1\n255\n220 210 190\n",
            PHOTO_B: b"P3\n1 1\n255\n10 20 30\n",
        }

        def http_get(url: str) -> bytes:
            if url.rstrip("/") == COMPASS_URL.rstrip("/") or url.startswith(COMPASS_URL):
                return html
            if url in photo_bytes:
                return photo_bytes[url]
            # origin rewrite variants for the second media hash may be requested as origin.jpg
            if url.endswith("/origin.jpg"):
                key = url
                for candidate, payload in photo_bytes.items():
                    if candidate.split("/")[-2] in url:
                        return payload
            raise AssertionError(f"unexpected URL {url}")

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get)

            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertTrue(snapshot.rights_confirmed)
            self.assertEqual(snapshot.address, "1002 Horne Dr")
            self.assertEqual(len(snapshot.photos), 2)
            self.assertTrue((root / "photos" / "photo_01.jpg").exists())
            self.assertTrue((root / "photos" / "photo_02.jpg").exists())
            self.assertTrue((root / "listing.json").exists())
            self.assertEqual(len(snapshot.photos[0].sha256), 64)

    def test_registry_requires_demo_or_rights_flag(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            blocked = build_authorized_fetcher(media_root=root, demo=False, rights_confirmed=False)
            with self.assertRaisesRegex(ListingManifestError, "demo-only"):
                blocked(COMPASS_URL)

    def test_registry_uses_compass_adapter_when_demo_enabled(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo = b"P3\n1 1\n255\n1 2 3\n"

        def http_get(url: str) -> bytes:
            if "homedetails" in url:
                return html
            return photo

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
            )
            snapshot = load_listing_source(COMPASS_URL, fetcher=fetcher)
            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertEqual(snapshot.city, "Cedar Park")
            self.assertGreaterEqual(len(snapshot.photos), 1)

    def test_zillow_still_never_calls_fetcher(self) -> None:
        fetcher = MagicMock()
        zillow = "https://www.zillow.com/homedetails/example/1_zpid/"
        self.assertEqual(source_policy_for(zillow), SourcePolicy.MANIFEST_ONLY)
        with self.assertRaisesRegex(ListingManifestError, "authorized manifest"):
            load_listing_source(zillow, fetcher=fetcher)
        fetcher.assert_not_called()

    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

if __name__ == "__main__":
    unittest.main()

+    def _make_parsed(self, photo_urls: tuple[str, ...]) -> CompassListingParse:

+            description="Demo listing.",

+            attribution="Demo intake.",

+    def test_materialize_rejects_below_min_photos(self) -> None:

+        """Fail closed: fewer than min_photos downloaded → CompassListingError."""

+            f"https://www.compass.com/m/{'a' * 64}/{i}/origin.jpg" for i in range(2)

+        parsed = self._make_parsed(photos)

+        with tempfile.TemporaryDirectory() as tmp:

+            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):

+                materialize_compass_listing(

+                    http_get=lambda url: dummy,

+    def test_materialize_accepts_at_min_photo_threshold(self) -> None:

+        """Exactly min_photos downloaded → succeeds."""

+            f"https://www.compass.com/m/{'a' * 62}{i:02d}/origin.jpg" for i in range(5)

+            snapshot = materialize_compass_listing(

+                http_get=lambda url: dummy,

+        self.assertEqual(len(snapshot.photos), 5)

+    def test_materialize_default_min_photos_is_five(self) -> None:

+        """Default min_photos=5; 2 photos raises without explicit kwarg."""

+            f"https://www.compass.com/m/{'b' * 64}/{i}/origin.jpg" for i in range(2)


Bash

cd /workspace/extra/projects/stagehand && python -m pytest tests/test_compass_provider.py -q 2>&1 | tail -15

Confirm new tests are RED before implementing the fix


/bin/bash: line 1: python: command not found


python3 -m pytest tests/test_compass_provider.py -q 2>&1 | tail -15

Run tests to confirm RED


/usr/bin/python3: No module named pytest


pip show pytest 2>/dev/null | grep -i name || pip install pytest -q && python3 -m pytest tests/test_compass_provider.py -q 2>&1 | tail -15

Install pytest if needed and run tests


Error: Exit code 1
error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

    If you wish to install a non-Debian-packaged Python package,
    create a virtual environment using python3 -m venv path/to/venv.
    Then use path/to/venv/bin/python and path/to/venv/bin/pip. Make
    sure you have python3-full installed.

    If you wish to install a non-Debian packaged Python application,
    it may be easiest to use pipx install xyz, which will manage a
    virtual environment for you. Make sure you have pipx installed.

    See /usr/share/doc/python3.11/README.venv for more information.

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.


cat pyproject.toml | grep -A5 "tool.pytest\|test\|script" | head -20

Check how tests are run in this project


description = "Concierge-first AI workflow service for local business media workflows"
requires-python = ">=3.11"
dependencies = [
  "anthropic",
  "elevenlabs",
  "ffmpeg-python",
--
[tool.pytest.ini_options]
testpaths = ["tests"]


ls .venv/bin/pytest 2>/dev/null || find /workspace/extra/projects/stagehand -name "pytest" 2>/dev/null | head -3

Find pytest in project virtualenv


.venv/bin/pytest


.venv/bin/pytest tests/test_compass_provider.py -q 2>&1 | tail -15

Run tests with project venv to confirm RED


/bin/bash: line 1: .venv/bin/pytest: cannot execute: required file not found


file .venv/bin/pytest && head -1 .venv/bin/pytest

Check pytest binary type


Error: Exit code 127
/bin/bash: line 1: file: command not found


ls -la .venv/bin/pytest && cat .venv/bin/pytest | head -5

Check pytest binary


-rwxr-xr-x 1 501 dialout 216 Jun 13 15:26 .venv/bin/pytest
#!/Users/jameslopez/projects/stagehand/.venv/bin/python
import sys
from _pytest.config import _console_main
if __name__ == '__main__':
    sys.argv[0] = sys.argv[0].removesuffix('.exe')


The pytest shebang references `/Users/jameslopez/projects/stagehand/.venv/bin/python` which is the Mac path. In the container, the project is at `/workspace/extra/projects/stagehand`. I need to use the Python from the venv directly.

Let me try calling it directly.


ls .venv/bin/python* 2>/dev/null && .venv/bin/python3 -m pytest tests/test_compass_provider.py -q 2>&1 | tail -15

Use venv python3 directly for pytest


.venv/bin/python
.venv/bin/python3
.venv/bin/python3.12
/bin/bash: line 1: .venv/bin/python3: No such file or directory


.venv/bin/python -m pytest tests/test_compass_provider.py -q 2>&1 | tail -15

Run tests using venv python


/bin/bash: line 1: .venv/bin/python: No such file or directory


ls -la /workspace/extra/projects/stagehand/.venv/bin/ | head -20

List venv bin contents to find the right python


total 96
drwxr-xr-x 27 501 dialout  864 Jul 11 15:59 .
drwxr-xr-x  6 501 dialout  192 Jun 13 15:26 ..
-rw-r--r--  1 501 dialout 9033 Jun 13 15:26 Activate.ps1
-rw-r--r--  1 501 dialout 2206 Jun 13 15:26 activate
-rw-r--r--  1 501 dialout  940 Jun 13 15:26 activate.csh
-rw-r--r--  1 501 dialout 2215 Jun 13 15:26 activate.fish
-rwxr-xr-x  1 501 dialout  198 Jun 13 15:28 alembic
-rwxr-xr-x  1 501 dialout  200 Jul 11 15:59 cffi-gen-src
-rwxr-xr-x  1 501 dialout  197 Jun 13 15:28 distro
-rwxr-xr-x  1 501 dialout  197 Jun 13 15:26 dotenv
-rwxr-xr-x  1 501 dialout  200 Jun 13 15:28 futurize
-rwxr-xr-x  1 501 dialout  189 Jun 13 15:28 httpx
-rwxr-xr-x  1 501 dialout  192 Jun 13 15:28 idna
-rwxr-xr-x  1 501 dialout  198 Jun 13 15:28 mako-render
-rwxr-xr-x  1 501 dialout  218 Jun 13 15:28 normalizer
-rwxr-xr-x  1 501 dialout  202 Jun 13 15:28 pasteurize
-rwxr-xr-x  1 501 dialout  258 Jun 13 15:26 pip
-rwxr-xr-x  1 501 dialout  258 Jun 13 15:26 pip3
-rwxr-xr-x  1 501 dialout  258 Jun 13 15:26 pip3.12


The venv was created on Mac (path shebang references /Users/jameslopez/projects/...) so the python symlinks point to the Mac path. They won't work in the container.

I need to use the system Python to run the tests. Let me check what Python is available and install pytest with pip --break-system-packages or use pipx.

Actually, looking at this differently: the project has a `.venv` that was built on Mac. The python binaries in it won't work here. But the test files and source code are the same.

Let me check if there's a way to run the tests with the container Python:
I should verify what packages the system Python has available, or try installing pytest directly with pip --break-system-packages. I could also check if setting PYTHONPATH would help me use the container's Python to run the tests.


PYTHONPATH=/workspace/extra/projects/stagehand python3 -c "import core.listings.providers.compass; print('ok')" 2>&1

Check if stagehand modules import with system Python


ok


pip3 install pytest --break-system-packages -q 2>&1 | tail -3

Install pytest for system Python


WARNING: Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:992)'))': /simple/pytest/
ERROR: Could not find a version that satisfies the requirement pytest (from versions: none)
ERROR: No matching distribution found for pytest


PYTHONPATH=/workspace/extra/projects/stagehand python3 -m unittest tests.test_compass_provider -v 2>&1 | tail -20

Run tests with stdlib unittest runner instead


Fail closed: fewer than min_photos downloaded → CompassListingError.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/workspace/extra/projects/stagehand/tests/test_compass_provider.py", line 149, in test_materialize_rejects_below_min_photos
    materialize_compass_listing(
TypeError: materialize_compass_listing() got an unexpected keyword argument 'min_photos'

======================================================================
FAIL: test_materialize_default_min_photos_is_five (tests.test_compass_provider.CompassProviderTests.test_materialize_default_min_photos_is_five)
Default min_photos=5; 2 photos raises without explicit kwarg.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/workspace/extra/projects/stagehand/tests/test_compass_provider.py", line 182, in test_materialize_default_min_photos_is_five
    with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
AssertionError: CompassListingError not raised

----------------------------------------------------------------------
Ran 10 tests in 0.003s

FAILED (failures=1, errors=2)


The tests are RED as expected:
- `test_materialize_rejects_below_min_photos`: TypeError because `min_photos` kwarg doesn't exist yet
- `test_materialize_default_min_photos_is_five`: fails because no min check exists
- `test_materialize_accepts_at_min_photo_threshold`: passes (silently, no error with 5 photos is correct behavior) — but wait, this should have succeeded... let me check

Actually, looking at the output: "Ran 10 tests" with 1 failure and 2 errors.

I need to look more carefully at which tests are erroring. The two TypeErrors are likely from `test_materialize_rejects_below_min_photos` and `test_materialize_accepts_at_min_photo_threshold` both hitting the missing `min_photos` parameter. The third test is probably also throwing a TypeError but got cut off in the output I was looking at. Rather than trying to trace through the exact test results, I should just go ahead and implement the fix to add the `min_photos` parameter to the function.

I'm adding the parameter with a default value of 5, then checking if the downloaded photos fall below that threshold and raising an appropriate error message that tells the user either to expand the gallery or lower the minimum.


Tests are RED as expected. Now implement the fix:


Edit

def materialize_compass_listing(
    parsed: CompassListingParse,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
) -> ListingSnapshot:

def materialize_compass_listing(
    parsed: CompassListingParse,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
    min_photos: int = 5,
) -> ListingSnapshot:


The file /workspace/extra/projects/stagehand/core/listings/providers/compass.py has been updated successfully. (file state is current in your context — no need to Read it back)

def materialize_compass_listing(
    parsed: CompassListingParse,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
) -> ListingSnapshot:

def materialize_compass_listing(
    parsed: CompassListingParse,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
    min_photos: int = 5,
) -> ListingSnapshot:

"""Download photo URLs into media_root and return a ListingSnapshot."""


    if not photos:
        raise CompassListingError("No Compass photos were downloaded")

    return ListingSnapshot(

    if not photos:
        raise CompassListingError("No Compass photos were downloaded")
    if len(photos) < min_photos:
        raise CompassListingError(
            f"Compass demo requires at least {min_photos} photos for a usable reel; "
            f"only {len(photos)} downloaded. Expand the gallery or lower min_photos."
        )

    return ListingSnapshot(


    if not photos:
        raise CompassListingError("No Compass photos were downloaded")

    return ListingSnapshot(

    if not photos:
        raise CompassListingError("No Compass photos were downloaded")
    if len(photos) < min_photos:
        raise CompassListingError(
            f"Compass demo requires at least {min_photos} photos for a usable reel; "
            f"only {len(photos)} downloaded. Expand the gallery or lower min_photos."
        )

    return ListingSnapshot(

+            f"Compass demo requires at least {min_photos} photos for a usable reel; "

+            f"only {len(photos)} downloaded. Expand the gallery or lower min_photos."


Bash

PYTHONPATH=/workspace/extra/projects/stagehand python3 -m unittest tests.test_compass_provider -v 2>&1 | tail -20

Run tests to confirm GREEN


  File "/workspace/extra/projects/stagehand/tests/test_compass_provider.py", line 104, in test_registry_uses_compass_adapter_when_demo_enabled
    snapshot = load_listing_source(COMPASS_URL, fetcher=fetcher)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/extra/projects/stagehand/core/listings/manifest.py", line 67, in load_listing_source
    snapshot = fetcher(normalized_url)
               ^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/extra/projects/stagehand/core/listings/providers/registry.py", line 43, in fetcher
    snapshot = fetch_compass_listing(
               ^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/extra/projects/stagehand/core/listings/providers/compass.py", line 260, in fetch_compass_listing
    snapshot = materialize_compass_listing(
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/workspace/extra/projects/stagehand/core/listings/providers/compass.py", line 199, in materialize_compass_listing
    raise CompassListingError(
core.listings.providers.compass.CompassListingError: Compass demo requires at least 5 photos for a usable reel; only 2 downloaded. Expand the gallery or lower min_photos.

----------------------------------------------------------------------
Ran 10 tests in 0.004s

FAILED (errors=2)


There's a pre-existing test that only provides 2 photos (`test_registry_uses_compass_adapter_when_demo_enabled` and `test_fetch_materializes_photos_and_manifest`) — they were written with the fixture that only has 2 photos (PHOTO_A and PHOTO_B). Now that I added a default `min_photos=5`, these tests fail because they only provide 2 photos.

I need to either:
1. Adjust the existing tests to provide 5+ photos, OR
2. Set `min_photos=2` in the existing test calls, OR
3. Change the default to 2 (but that defeats the purpose)
The cleanest approach is to update those tests to explicitly pass `min_photos=2` so they work with the minimal 2-photo fixture without changing the default constraint.

But I need to verify whether `fetch_compass_listing` actually passes the `min_photos` parameter through to the internal `materialize_compass_listing` call.


The existing 2-photo fixture tests now trip the new guard. Let me check `fetch_compass_listing` to see if `min_photos` threads through, then update the fixture tests:


240	    return path
241
242
243	def fetch_compass_listing(
244	    listing_url: str,
245	    *,
246	    media_root: str | Path,
247	    http_get: HttpGet | None = None,
248	    max_photos: int = 20,
249	) -> ListingSnapshot:
250	    if not is_compass_listing_url(listing_url):
251	        raise CompassListingError("Not a Compass listing URL")
252	    getter = http_get or _default_http_get
253	    try:
254	        html = getter(normalize_listing_url(listing_url)).decode("utf-8", "replace")
255	    except CompassListingError:
256	        raise
257	    except Exception as exc:
258	        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
259	    parsed = parse_compass_listing_html(html, listing_url=listing_url)

    return path

def fetch_compass_listing(
    listing_url: str,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
) -> ListingSnapshot:
    if not is_compass_listing_url(listing_url):
        raise CompassListingError("Not a Compass listing URL")
    getter = http_get or _default_http_get
    try:
        html = getter(normalize_listing_url(listing_url)).decode("utf-8", "replace")
    except CompassListingError:
        raise
    except Exception as exc:
        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
    parsed = parse_compass_listing_html(html, listing_url=listing_url)


258	        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
259	    parsed = parse_compass_listing_html(html, listing_url=listing_url)
260	    snapshot = materialize_compass_listing(
261	        parsed,
262	        media_root=media_root,
263	        http_get=getter,
264	        max_photos=max_photos,
265	    )
266	    write_manifest_from_snapshot(snapshot, media_root)
267	    return snapshot

        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
    parsed = parse_compass_listing_html(html, listing_url=listing_url)
    snapshot = materialize_compass_listing(
        parsed,
        media_root=media_root,
        http_get=getter,
        max_photos=max_photos,
    )
    write_manifest_from_snapshot(snapshot, media_root)
    return snapshot


`fetch_compass_listing` doesn't pass `min_photos` through to `materialize_compass_listing`. I need to:
1. Add `min_photos` parameter to `fetch_compass_listing`
2. Pass it through to `materialize_compass_listing`
3. Update the existing tests that use the 2-photo fixture to explicitly pass `min_photos=2` (since they're testing functionality, not the min-photos guard)

Actually, there are two ways to handle the existing tests:
1. Add `min_photos=2` to the `fetch_compass_listing` calls in the existing tests
2. Or change the fixture to have 5+ photos

Option 1 is cleaner for the tests - they're testing photo materialization and manifest writing, not the photo count guard. So explicitly setting `min_photos=2` (or even `min_photos=0` or `min_photos=1`) makes the intent clear: "this test doesn't test the min_photos constraint." I'll thread `min_photos` through `fetch_compass_listing` and update the test calls to pass `min_photos=2` explicitly.

For `test_registry_uses_compass_adapter_when_demo_enabled`, which goes through `build_authorized_fetcher` → `fetch_compass_listing`, I need to thread `min_photos` all the way down. The simplest approach is to add the parameter to `build_authorized_fetcher` as well, then pass `min_photos=1` or `min_photos=2` in tests that aren't specifically testing the photo count constraint.


I need to thread `min_photos` through `fetch_compass_listing` and `build_authorized_fetcher`, then update the existing fixture tests to bypass the guard since they test different things:


1	"""Source-provider registry for authorized listing adapters."""
2
3	from __future__ import annotations
4
5	from collections.abc import Callable
6	from pathlib import Path
7	from urllib.parse import urlsplit
8
9	from core.listings.manifest import ListingManifestError, normalize_listing_url
10	from core.listings.models import ListingSnapshot
11	from core.listings.providers.compass import fetch_compass_listing, is_compass_listing_url
12
13
14	Fetcher = Callable[[str], ListingSnapshot]
15
16
17	def build_authorized_fetcher(
18	    *,
19	    media_root: str | Path,
20	    demo: bool = False,
21	    rights_confirmed: bool = False,
22	    http_get: Callable[[str], bytes] | None = None,
23	    max_photos: int = 20,
24	) -> Fetcher:
25	    """Return a fetcher that routes allowlisted domains to their adapter.
26
27	    Demo adapters require an explicit demo opt-in. Shipping still needs a real
28	    rights path; demo status is recorded on the snapshot provider string and
29	    attribution so the approval bundle remains honest.
30	    """
31
32	    root = Path(media_root)
33
34	    def fetcher(listing_url: str) -> ListingSnapshot:
35	        normalized = normalize_listing_url(listing_url)
36	        host = (urlsplit(normalized).hostname or "").lower()
37	        if is_compass_listing_url(normalized):
38	            if not (demo or rights_confirmed):
39	                raise ListingManifestError(
40	                    "Compass public page intake is demo-only without broker rights attestation. "
41	                    "Pass --demo (pilot) or --rights-confirmed once brokerage/photographer authorization exists."
42	                )
43	            snapshot = fetch_compass_listing(
44	                normalized,
45	                media_root=root,
46	                http_get=http_get,
47	                max_photos=max_photos,
48	            )
49	            if rights_confirmed and not demo:
50	                # Preserve demo string unless rights were explicitly asserted for a future
51	                # non-demo production path. For now both flags use the public demo provider,
52	                # but attribution already states the demojudge role; rights_confirmed is
53	                # still required as an operator assertion.
54	                return snapshot
55	            return snapshot
56	        raise ListingManifestError(
57	            f"No authorized listing adapter is configured for {host or 'unknown host'}. "
58	            "Supply --manifest/--media-root, or use a supported demo source (compass.com)."
59	        )
60
61	    return fetcher
62

"""Source-provider registry for authorized listing adapters."""

from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlsplit

from core.listings.manifest import ListingManifestError, normalize_listing_url
from core.listings.models import ListingSnapshot
from core.listings.providers.compass import fetch_compass_listing, is_compass_listing_url

Fetcher = Callable[[str], ListingSnapshot]

def build_authorized_fetcher(
    *,
    media_root: str | Path,
    demo: bool = False,
    rights_confirmed: bool = False,
    http_get: Callable[[str], bytes] | None = None,
    max_photos: int = 20,
) -> Fetcher:
    """Return a fetcher that routes allowlisted domains to their adapter.

    Demo adapters require an explicit demo opt-in. Shipping still needs a real
    rights path; demo status is recorded on the snapshot provider string and
    attribution so the approval bundle remains honest.
    """

    root = Path(media_root)

    def fetcher(listing_url: str) -> ListingSnapshot:
        normalized = normalize_listing_url(listing_url)
        host = (urlsplit(normalized).hostname or "").lower()
        if is_compass_listing_url(normalized):
            if not (demo or rights_confirmed):
                raise ListingManifestError(
                    "Compass public page intake is demo-only without broker rights attestation. "
                    "Pass --demo (pilot) or --rights-confirmed once brokerage/photographer authorization exists."
                )
            snapshot = fetch_compass_listing(
                normalized,
                media_root=root,
                http_get=http_get,
                max_photos=max_photos,
            )
            if rights_confirmed and not demo:
                # Preserve demo string unless rights were explicitly asserted for a future
                # non-demo production path. For now both flags use the public demo provider,
                # but attribution already states the demojudge role; rights_confirmed is
                # still required as an operator assertion.
                return snapshot
            return snapshot
        raise ListingManifestError(
            f"No authorized listing adapter is configured for {host or 'unknown host'}. "
            "Supply --manifest/--media-root, or use a supported demo source (compass.com)."
        )

    return fetcher


Now I'll thread `min_photos` through both `fetch_compass_listing` and `build_authorized_fetcher`, then update the fixture tests to explicitly set `min_photos=2`:


Edit

def fetch_compass_listing(
    listing_url: str,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
) -> ListingSnapshot:
    if not is_compass_listing_url(listing_url):
        raise CompassListingError("Not a Compass listing URL")
    getter = http_get or _default_http_get
    try:
        html = getter(normalize_listing_url(listing_url)).decode("utf-8", "replace")
    except CompassListingError:
        raise
    except Exception as exc:
        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
    parsed = parse_compass_listing_html(html, listing_url=listing_url)
    snapshot = materialize_compass_listing(
        parsed,
        media_root=media_root,
        http_get=getter,
        max_photos=max_photos,
    )
    write_manifest_from_snapshot(snapshot, media_root)
    return snapshot

def fetch_compass_listing(
    listing_url: str,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
    min_photos: int = 5,
) -> ListingSnapshot:
    if not is_compass_listing_url(listing_url):
        raise CompassListingError("Not a Compass listing URL")
    getter = http_get or _default_http_get
    try:
        html = getter(normalize_listing_url(listing_url)).decode("utf-8", "replace")
    except CompassListingError:
        raise
    except Exception as exc:
        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
    parsed = parse_compass_listing_html(html, listing_url=listing_url)
    snapshot = materialize_compass_listing(
        parsed,
        media_root=media_root,
        http_get=getter,
        max_photos=max_photos,
        min_photos=min_photos,
    )
    write_manifest_from_snapshot(snapshot, media_root)
    return snapshot


def fetch_compass_listing(
    listing_url: str,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
) -> ListingSnapshot:
    if not is_compass_listing_url(listing_url):
        raise CompassListingError("Not a Compass listing URL")
    getter = http_get or _default_http_get
    try:
        html = getter(normalize_listing_url(listing_url)).decode("utf-8", "replace")
    except CompassListingError:
        raise
    except Exception as exc:
        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
    parsed = parse_compass_listing_html(html, listing_url=listing_url)
    snapshot = materialize_compass_listing(
        parsed,
        media_root=media_root,
        http_get=getter,
        max_photos=max_photos,
    )
    write_manifest_from_snapshot(snapshot, media_root)
    return snapshot

def fetch_compass_listing(
    listing_url: str,
    *,
    media_root: str | Path,
    http_get: HttpGet | None = None,
    max_photos: int = 20,
    min_photos: int = 5,
) -> ListingSnapshot:
    if not is_compass_listing_url(listing_url):
        raise CompassListingError("Not a Compass listing URL")
    getter = http_get or _default_http_get
    try:
        html = getter(normalize_listing_url(listing_url)).decode("utf-8", "replace")
    except CompassListingError:
        raise
    except Exception as exc:
        raise CompassListingError(f"Failed to fetch Compass listing page: {exc}") from exc
    parsed = parse_compass_listing_html(html, listing_url=listing_url)
    snapshot = materialize_compass_listing(
        parsed,
        media_root=media_root,
        http_get=getter,
        max_photos=max_photos,
        min_photos=min_photos,
    )
    write_manifest_from_snapshot(snapshot, media_root)
    return snapshot


def build_authorized_fetcher(
    *,
    media_root: str | Path,
    demo: bool = False,
    rights_confirmed: bool = False,
    http_get: Callable[[str], bytes] | None = None,
    max_photos: int = 20,
) -> Fetcher:

def build_authorized_fetcher(
    *,
    media_root: str | Path,
    demo: bool = False,
    rights_confirmed: bool = False,
    http_get: Callable[[str], bytes] | None = None,
    max_photos: int = 20,
    min_photos: int = 5,
) -> Fetcher:


The file /workspace/extra/projects/stagehand/core/listings/providers/registry.py has been updated successfully. (file state is current in your context — no need to Read it back)

def build_authorized_fetcher(
    *,
    media_root: str | Path,
    demo: bool = False,
    rights_confirmed: bool = False,
    http_get: Callable[[str], bytes] | None = None,
    max_photos: int = 20,
    min_photos: int = 5,
) -> Fetcher:

"""Source-provider registry for authorized listing adapters."""

from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlsplit

from core.listings.manifest import ListingManifestError, normalize_listing_url
from core.listings.models import ListingSnapshot
from core.listings.providers.compass import fetch_compass_listing, is_compass_listing_url

Fetcher = Callable[[str], ListingSnapshot]

def build_authorized_fetcher(
    *,
    media_root: str | Path,
    demo: bool = False,
    rights_confirmed: bool = False,
    http_get: Callable[[str], bytes] | None = None,
    max_photos: int = 20,
) -> Fetcher:
    """Return a fetcher that routes allowlisted domains to their adapter.

    Demo adapters require an explicit demo opt-in. Shipping still needs a real
    rights path; demo status is recorded on the snapshot provider string and
    attribution so the approval bundle remains honest.
    """

    root = Path(media_root)

    def fetcher(listing_url: str) -> ListingSnapshot:
        normalized = normalize_listing_url(listing_url)
        host = (urlsplit(normalized).hostname or "").lower()
        if is_compass_listing_url(normalized):
            if not (demo or rights_confirmed):
                raise ListingManifestError(
                    "Compass public page intake is demo-only without broker rights attestation. "
                    "Pass --demo (pilot) or --rights-confirmed once brokerage/photographer authorization exists."
                )
            snapshot = fetch_compass_listing(
                normalized,
                media_root=root,
                http_get=http_get,
                max_photos=max_photos,
            )
            if rights_confirmed and not demo:
                # Preserve demo string unless rights were explicitly asserted for a future
                # non-demo production path. For now both flags use the public demo provider,
                # but attribution already states the demojudge role; rights_confirmed is
                # still required as an operator assertion.
                return snapshot
            return snapshot
        raise ListingManifestError(
            f"No authorized listing adapter is configured for {host or 'unknown host'}. "
            "Supply --manifest/--media-root, or use a supported demo source (compass.com)."
        )

    return fetcher


            snapshot = fetch_compass_listing(
                normalized,
                media_root=root,
                http_get=http_get,
                max_photos=max_photos,
            )

            snapshot = fetch_compass_listing(
                normalized,
                media_root=root,
                http_get=http_get,
                max_photos=max_photos,
                min_photos=min_photos,
            )


            snapshot = fetch_compass_listing(
                normalized,
                media_root=root,
                http_get=http_get,
                max_photos=max_photos,
                min_photos=min_photos,
            )

"""Source-provider registry for authorized listing adapters."""

from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlsplit

from core.listings.manifest import ListingManifestError, normalize_listing_url
from core.listings.models import ListingSnapshot
from core.listings.providers.compass import fetch_compass_listing, is_compass_listing_url

Fetcher = Callable[[str], ListingSnapshot]

def build_authorized_fetcher(
    *,
    media_root: str | Path,
    demo: bool = False,
    rights_confirmed: bool = False,
    http_get: Callable[[str], bytes] | None = None,
    max_photos: int = 20,
    min_photos: int = 5,
) -> Fetcher:
    """Return a fetcher that routes allowlisted domains to their adapter.

    Demo adapters require an explicit demo opt-in. Shipping still needs a real
    rights path; demo status is recorded on the snapshot provider string and
    attribution so the approval bundle remains honest.
    """

    root = Path(media_root)

    def fetcher(listing_url: str) -> ListingSnapshot:
        normalized = normalize_listing_url(listing_url)
        host = (urlsplit(normalized).hostname or "").lower()
        if is_compass_listing_url(normalized):
            if not (demo or rights_confirmed):
                raise ListingManifestError(
                    "Compass public page intake is demo-only without broker rights attestation. "
                    "Pass --demo (pilot) or --rights-confirmed once brokerage/photographer authorization exists."
                )
            snapshot = fetch_compass_listing(
                normalized,
                media_root=root,
                http_get=http_get,
                max_photos=max_photos,
            )
            if rights_confirmed and not demo:
                # Preserve demo string unless rights were explicitly asserted for a future
                # non-demo production path. For now both flags use the public demo provider,
                # but attribution already states the demojudge role; rights_confirmed is
                # still required as an operator assertion.
                return snapshot
            return snapshot
        raise ListingManifestError(
            f"No authorized listing adapter is configured for {host or 'unknown host'}. "
            "Supply --manifest/--media-root, or use a supported demo source (compass.com)."
        )

    return fetcher


Now update the two existing fixture tests to pass `min_photos=2` since the fixture only has 2 photos and they're testing other things:


        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get)

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            # Fixture has only 2 photos; min_photos=2 bypasses the guard (tested separately).
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get, min_photos=2)


The file /workspace/extra/projects/stagehand/tests/test_compass_provider.py has been updated successfully. (file state is current in your context — no need to Read it back)

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            # Fixture has only 2 photos; min_photos=2 bypasses the guard (tested separately).
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get, min_photos=2)

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock

from core.listings.manifest import ListingManifestError, SourcePolicy, load_listing_source, source_policy_for
from core.listings.providers.compass import (
    CompassListingError,
    CompassListingParse,
    fetch_compass_listing,
    is_compass_listing_url,
    materialize_compass_listing,
    parse_compass_listing_html,
)
from core.listings.providers.registry import build_authorized_fetcher

FIXTURE_HTML = Path(__file__).parent / "fixtures" / "compass" / "sample_listing.html"
COMPASS_URL = "https://www.compass.com/homedetails/1002-Horne-Dr-Cedar-Park-TX-78613/1877020858229275065_lid/"
PHOTO_A = "https://www.compass.com/m/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/origin.jpg"
PHOTO_B = "https://www.compass.com/m/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/origin.jpg"

class CompassProviderTests(unittest.TestCase):
    def test_source_policy_allows_compass_adapter_path(self) -> None:
        self.assertTrue(is_compass_listing_url(COMPASS_URL))
        self.assertEqual(source_policy_for(COMPASS_URL), SourcePolicy.AUTHORIZED_ADAPTER)

    def test_parse_fixture_html_extracts_facts_and_deduped_photos(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8")
        parsed = parse_compass_listing_html(html, listing_url=COMPASS_URL)

        self.assertEqual(parsed.address, "1002 Horne Dr")
        self.assertEqual(parsed.city, "Cedar Park")
        self.assertEqual(parsed.state, "TX")
        self.assertEqual(parsed.postal_code, "78613")
        self.assertEqual(parsed.price, 692500)
        self.assertEqual(parsed.beds, 5)
        self.assertEqual(parsed.baths, 3)
        self.assertEqual(parsed.square_feet, 2684)
        self.assertIn("Cypress Canyon", parsed.description)
        self.assertEqual(parsed.agent_name, "Richard Fowler")
        self.assertEqual(parsed.photo_urls, (PHOTO_A, PHOTO_B))
        self.assertIn("pilot review only", parsed.attribution)

    def test_fetch_materializes_photos_and_manifest(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo_bytes = {
            PHOTO_A: b"P3\n1 1\n255\n220 210 190\n",
            PHOTO_B: b"P3\n1 1\n255\n10 20 30\n",
        }

        def http_get(url: str) -> bytes:
            if url.rstrip("/") == COMPASS_URL.rstrip("/") or url.startswith(COMPASS_URL):
                return html
            if url in photo_bytes:
                return photo_bytes[url]
            # origin rewrite variants for the second media hash may be requested as origin.jpg
            if url.endswith("/origin.jpg"):
                key = url
                for candidate, payload in photo_bytes.items():
                    if candidate.split("/")[-2] in url:
                        return payload
            raise AssertionError(f"unexpected URL {url}")

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get)

            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertTrue(snapshot.rights_confirmed)
            self.assertEqual(snapshot.address, "1002 Horne Dr")
            self.assertEqual(len(snapshot.photos), 2)
            self.assertTrue((root / "photos" / "photo_01.jpg").exists())
            self.assertTrue((root / "photos" / "photo_02.jpg").exists())
            self.assertTrue((root / "listing.json").exists())
            self.assertEqual(len(snapshot.photos[0].sha256), 64)

    def test_registry_requires_demo_or_rights_flag(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            blocked = build_authorized_fetcher(media_root=root, demo=False, rights_confirmed=False)
            with self.assertRaisesRegex(ListingManifestError, "demo-only"):
                blocked(COMPASS_URL)

    def test_registry_uses_compass_adapter_when_demo_enabled(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo = b"P3\n1 1\n255\n1 2 3\n"

        def http_get(url: str) -> bytes:
            if "homedetails" in url:
                return html
            return photo

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
            )
            snapshot = load_listing_source(COMPASS_URL, fetcher=fetcher)
            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertEqual(snapshot.city, "Cedar Park")
            self.assertGreaterEqual(len(snapshot.photos), 1)

    def test_zillow_still_never_calls_fetcher(self) -> None:
        fetcher = MagicMock()
        zillow = "https://www.zillow.com/homedetails/example/1_zpid/"
        self.assertEqual(source_policy_for(zillow), SourcePolicy.MANIFEST_ONLY)
        with self.assertRaisesRegex(ListingManifestError, "authorized manifest"):
            load_listing_source(zillow, fetcher=fetcher)
        fetcher.assert_not_called()

    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

    # --- min_photos guard ---

    def _make_parsed(self, photo_urls: tuple[str, ...]) -> CompassListingParse:
        return CompassListingParse(
            source_url=COMPASS_URL,
            address="1002 Horne Dr",
            city="Cedar Park",
            state="TX",
            postal_code="78613",
            price=692500,
            beds=5,
            baths=3,
            square_feet=2684,
            description="Demo listing.",
            photo_urls=photo_urls,
            attribution="Demo intake.",
        )

    def test_materialize_rejects_below_min_photos(self) -> None:
        """Fail closed: fewer than min_photos downloaded → CompassListingError."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                    min_photos=5,
                )

    def test_materialize_accepts_at_min_photo_threshold(self) -> None:
        """Exactly min_photos downloaded → succeeds."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 62}{i:02d}/origin.jpg" for i in range(5)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            snapshot = materialize_compass_listing(
                parsed,
                media_root=tmp,
                http_get=lambda url: dummy,
                min_photos=5,
            )
        self.assertEqual(len(snapshot.photos), 5)

    def test_materialize_default_min_photos_is_five(self) -> None:
        """Default min_photos=5; 2 photos raises without explicit kwarg."""
        photos = tuple(
            f"https://www.compass.com/m/{'b' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n5 6 7\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                )

if __name__ == "__main__":
    unittest.main()

-            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get)

+            # Fixture has only 2 photos; min_photos=2 bypasses the guard (tested separately).

+            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get, min_photos=2)


        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
            )

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            # Fixture has only 2 photos; min_photos=2 bypasses the guard (tested separately).
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
                min_photos=2,
            )


        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            # Fixture has only 2 photos; min_photos=2 bypasses the guard (tested separately).
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
                min_photos=2,
            )

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock

from core.listings.manifest import ListingManifestError, SourcePolicy, load_listing_source, source_policy_for
from core.listings.providers.compass import (
    CompassListingError,
    CompassListingParse,
    fetch_compass_listing,
    is_compass_listing_url,
    materialize_compass_listing,
    parse_compass_listing_html,
)
from core.listings.providers.registry import build_authorized_fetcher

FIXTURE_HTML = Path(__file__).parent / "fixtures" / "compass" / "sample_listing.html"
COMPASS_URL = "https://www.compass.com/homedetails/1002-Horne-Dr-Cedar-Park-TX-78613/1877020858229275065_lid/"
PHOTO_A = "https://www.compass.com/m/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/origin.jpg"
PHOTO_B = "https://www.compass.com/m/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/origin.jpg"

class CompassProviderTests(unittest.TestCase):
    def test_source_policy_allows_compass_adapter_path(self) -> None:
        self.assertTrue(is_compass_listing_url(COMPASS_URL))
        self.assertEqual(source_policy_for(COMPASS_URL), SourcePolicy.AUTHORIZED_ADAPTER)

    def test_parse_fixture_html_extracts_facts_and_deduped_photos(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8")
        parsed = parse_compass_listing_html(html, listing_url=COMPASS_URL)

        self.assertEqual(parsed.address, "1002 Horne Dr")
        self.assertEqual(parsed.city, "Cedar Park")
        self.assertEqual(parsed.state, "TX")
        self.assertEqual(parsed.postal_code, "78613")
        self.assertEqual(parsed.price, 692500)
        self.assertEqual(parsed.beds, 5)
        self.assertEqual(parsed.baths, 3)
        self.assertEqual(parsed.square_feet, 2684)
        self.assertIn("Cypress Canyon", parsed.description)
        self.assertEqual(parsed.agent_name, "Richard Fowler")
        self.assertEqual(parsed.photo_urls, (PHOTO_A, PHOTO_B))
        self.assertIn("pilot review only", parsed.attribution)

    def test_fetch_materializes_photos_and_manifest(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo_bytes = {
            PHOTO_A: b"P3\n1 1\n255\n220 210 190\n",
            PHOTO_B: b"P3\n1 1\n255\n10 20 30\n",
        }

        def http_get(url: str) -> bytes:
            if url.rstrip("/") == COMPASS_URL.rstrip("/") or url.startswith(COMPASS_URL):
                return html
            if url in photo_bytes:
                return photo_bytes[url]
            # origin rewrite variants for the second media hash may be requested as origin.jpg
            if url.endswith("/origin.jpg"):
                key = url
                for candidate, payload in photo_bytes.items():
                    if candidate.split("/")[-2] in url:
                        return payload
            raise AssertionError(f"unexpected URL {url}")

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            # Fixture has only 2 photos; min_photos=2 bypasses the guard (tested separately).
            snapshot = fetch_compass_listing(COMPASS_URL, media_root=root, http_get=http_get, min_photos=2)

            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertTrue(snapshot.rights_confirmed)
            self.assertEqual(snapshot.address, "1002 Horne Dr")
            self.assertEqual(len(snapshot.photos), 2)
            self.assertTrue((root / "photos" / "photo_01.jpg").exists())
            self.assertTrue((root / "photos" / "photo_02.jpg").exists())
            self.assertTrue((root / "listing.json").exists())
            self.assertEqual(len(snapshot.photos[0].sha256), 64)

    def test_registry_requires_demo_or_rights_flag(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            blocked = build_authorized_fetcher(media_root=root, demo=False, rights_confirmed=False)
            with self.assertRaisesRegex(ListingManifestError, "demo-only"):
                blocked(COMPASS_URL)

    def test_registry_uses_compass_adapter_when_demo_enabled(self) -> None:
        html = FIXTURE_HTML.read_text(encoding="utf-8").encode("utf-8")
        photo = b"P3\n1 1\n255\n1 2 3\n"

        def http_get(url: str) -> bytes:
            if "homedetails" in url:
                return html
            return photo

        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            fetcher = build_authorized_fetcher(
                media_root=root,
                demo=True,
                rights_confirmed=False,
                http_get=http_get,
            )
            snapshot = load_listing_source(COMPASS_URL, fetcher=fetcher)
            self.assertEqual(snapshot.source_provider, "compass_public_demo")
            self.assertEqual(snapshot.city, "Cedar Park")
            self.assertGreaterEqual(len(snapshot.photos), 1)

    def test_zillow_still_never_calls_fetcher(self) -> None:
        fetcher = MagicMock()
        zillow = "https://www.zillow.com/homedetails/example/1_zpid/"
        self.assertEqual(source_policy_for(zillow), SourcePolicy.MANIFEST_ONLY)
        with self.assertRaisesRegex(ListingManifestError, "authorized manifest"):
            load_listing_source(zillow, fetcher=fetcher)
        fetcher.assert_not_called()

    def test_parse_rejects_empty_html(self) -> None:
        with self.assertRaisesRegex(CompassListingError, "empty"):
            parse_compass_listing_html("   ", listing_url=COMPASS_URL)

    # --- min_photos guard ---

    def _make_parsed(self, photo_urls: tuple[str, ...]) -> CompassListingParse:
        return CompassListingParse(
            source_url=COMPASS_URL,
            address="1002 Horne Dr",
            city="Cedar Park",
            state="TX",
            postal_code="78613",
            price=692500,
            beds=5,
            baths=3,
            square_feet=2684,
            description="Demo listing.",
            photo_urls=photo_urls,
            attribution="Demo intake.",
        )

    def test_materialize_rejects_below_min_photos(self) -> None:
        """Fail closed: fewer than min_photos downloaded → CompassListingError."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                    min_photos=5,
                )

    def test_materialize_accepts_at_min_photo_threshold(self) -> None:
        """Exactly min_photos downloaded → succeeds."""
        photos = tuple(
            f"https://www.compass.com/m/{'a' * 62}{i:02d}/origin.jpg" for i in range(5)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n1 2 3\n"

        with tempfile.TemporaryDirectory() as tmp:
            snapshot = materialize_compass_listing(
                parsed,
                media_root=tmp,
                http_get=lambda url: dummy,
                min_photos=5,
            )
        self.assertEqual(len(snapshot.photos), 5)

    def test_materialize_default_min_photos_is_five(self) -> None:
        """Default min_photos=5; 2 photos raises without explicit kwarg."""
        photos = tuple(
            f"https://www.compass.com/m/{'b' * 64}/{i}/origin.jpg" for i in range(2)
        )
        parsed = self._make_parsed(photos)
        dummy = b"P3\n1 1\n255\n5 6 7\n"

        with tempfile.TemporaryDirectory() as tmp:
            with self.assertRaisesRegex(CompassListingError, "at least 5 photos"):
                materialize_compass_listing(
                    parsed,
                    media_root=tmp,
                    http_get=lambda url: dummy,
                )

if __name__ == "__main__":
    unittest.main()


Bash

PYTHONPATH=/workspace/extra/projects/stagehand python3 -m unittest tests.test_compass_provider -v 2>&1 | tail -20

Run all compass tests to confirm GREEN


test_fetch_materializes_photos_and_manifest (tests.test_compass_provider.CompassProviderTests.test_fetch_materializes_photos_and_manifest) ... ok
test_materialize_accepts_at_min_photo_threshold (tests.test_compass_provider.CompassProviderTests.test_materialize_accepts_at_min_photo_threshold)
Exactly min_photos downloaded → succeeds. ... ok
test_materialize_default_min_photos_is_five (tests.test_compass_provider.CompassProviderTests.test_materialize_default_min_photos_is_five)
Default min_photos=5; 2 photos raises without explicit kwarg. ... ok
test_materialize_rejects_below_min_photos (tests.test_compass_provider.CompassProviderTests.test_materialize_rejects_below_min_photos)
Fail closed: fewer than min_photos downloaded → CompassListingError. ... ok
test_parse_fixture_html_extracts_facts_and_deduped_photos (tests.test_compass_provider.CompassProviderTests.test_parse_fixture_html_extracts_facts_and_deduped_photos) ... ok
test_parse_rejects_empty_html (tests.test_compass_provider.CompassProviderTests.test_parse_rejects_empty_html) ... ok
test_registry_requires_demo_or_rights_flag (tests.test_compass_provider.CompassProviderTests.test_registry_requires_demo_or_rights_flag) ... ok
test_registry_uses_compass_adapter_when_demo_enabled (tests.test_compass_provider.CompassProviderTests.test_registry_uses_compass_adapter_when_demo_enabled) ... ok
test_source_policy_allows_compass_adapter_path (tests.test_compass_provider.CompassProviderTests.test_source_policy_allows_compass_adapter_path) ... ok
test_zillow_still_never_calls_fetcher (tests.test_compass_provider.CompassProviderTests.test_zillow_still_never_calls_fetcher) ... ok

----------------------------------------------------------------------
Ran 10 tests in 0.003s

OK


10/10 GREEN. Now commit and update the punch list and board:


git config user.email "nano@nanoclaw.dev" && git config user.name "Nano" && git add core/listings/providers/compass.py core/listings/providers/registry.py tests/test_compass_provider.py && git commit -m "$(cat <<'EOF'
fix(compass): fail closed below min photo count; thread min_photos

The Compass demo adapter previously only rejected listings with zero
downloaded photos, silently accepting thin galleries that would produce
unusable reels.

Changes:
- materialize_compass_listing: add min_photos=5 (default). Raises
  CompassListingError with a clear message when fewer photos download.
- fetch_compass_listing, build_authorized_fetcher: thread min_photos
  through so callers can lower the threshold for tests or special cases.
- test_compass_provider: 3 new RED→GREEN tests covering the guard
  (reject-below, accept-at-threshold, default-is-five); existing
  2-photo fixture tests annotated with min_photos=2 to document intent.

10/10 tests GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Commit the Compass min_photos fix


[feature/listing-reel-workflow e8bcf28] fix(compass): fail closed below min photo count; thread min_photos
 3 files changed, 84 insertions(+), 1 deletion(-)


Wait, the commit went to a branch `feature/listing-reel-workflow`, not `main`. That's interesting - the repo has a feature branch checked out. Let me check if this is expected.

Actually, for StageHand, this is probably fine. The project uses feature branches. Let me check which branch was active and whether I should note this in the card.

Actually, the commit went to `feature/listing-reel-workflow` which is the current branch. This is the right place for the work - I shouldn't push or merge without James's review (the guard says autonomous-ok, but for feature branches I should note that the work is on a branch).
Since the guard permits autonomous work, I can proceed with updating the punch list and project card, then wrap up with a summary of what was completed.


Check which branch the commit landed on


feature/listing-reel-workflow


Edit

- [ ] Expand Compass gallery capture further / fail closed below min photo count for demos.

- [x] Fail closed below min photo count for demos — `min_photos=5` default added to `materialize_compass_listing` + threaded through `fetch_compass_listing` / `build_authorized_fetcher`; 3 RED→GREEN tests (e8bcf28).
- [ ] Expand Compass gallery capture further — lazy-loaded/paginated photos beyond initial HTML metadata.


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


Bash

grep -n "Expand Compass" /workspace/extra/projects/stagehand/docs/punch-list.md

Find the exact line to update


67:- [ ] Expand Compass gallery capture further / fail closed below min photo count for demos.


65	- [ ] Wire natural voice default to Richard’s shared ElevenLabs PVC once he completes PVC share + consent.
66	- [ ] Optional: Coqui XTTS as first-class listing-reel TTS provider (research-only until PVC lands).
67	- [ ] Expand Compass gallery capture further / fail closed below min photo count for demos.
68	- [ ] PureRealty adapter after we clear cookie/login or get an authorized feed/export.
69	- [ ] Production rights path: broker attestation workflow + licensed Bridge/RESO option.

- [ ] Wire natural voice default to Richard’s shared ElevenLabs PVC once he completes PVC share + consent.
- [ ] Optional: Coqui XTTS as first-class listing-reel TTS provider (research-only until PVC lands).
- [ ] Expand Compass gallery capture further / fail closed below min photo count for demos.
- [ ] PureRealty adapter after we clear cookie/login or get an authorized feed/export.
- [ ] Production rights path: broker attestation workflow + licensed Bridge/RESO option.


Edit

- [ ] Expand Compass gallery capture further / fail closed below min photo count for demos.

- [x] Fail closed below min photo count for demos — `min_photos=5` default added to `materialize_compass_listing` + threaded through `fetch_compass_listing` / `build_authorized_fetcher`; 3 RED→GREEN tests (e8bcf28 on feature/listing-reel-workflow).
- [ ] Expand Compass gallery capture further — lazy-loaded/paginated photos beyond initial HTML metadata.