Skip to content

Commit a166ae3

Browse files
authored
Merge pull request #2 from grimlor/refactor/job-board-port-protocol
refactor(adapters): extract JobBoardPort Protocol from JobBoardAdapter ABC
2 parents 49a953e + 0d45215 commit a166ae3

6 files changed

Lines changed: 73 additions & 19 deletions

File tree

src/jobsearch_rag/adapters/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ziprecruiter as _zr, # noqa: F401 # pyright: ignore[reportUnusedImport] # side-effect: triggers @register
1414
)
1515
from jobsearch_rag.adapters.base import JobBoardAdapter, JobListing
16+
from jobsearch_rag.adapters.ports import JobBoardPort
1617
from jobsearch_rag.adapters.registry import AdapterRegistry
1718

18-
__all__ = ["AdapterRegistry", "JobBoardAdapter", "JobListing"]
19+
__all__ = ["AdapterRegistry", "JobBoardAdapter", "JobBoardPort", "JobListing"]
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Protocol defining the structural interface for job board adapters."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
6+
7+
if TYPE_CHECKING:
8+
from playwright.async_api import Page
9+
10+
from jobsearch_rag.adapters.base import JobListing
11+
12+
13+
@runtime_checkable
14+
class JobBoardPort(Protocol):
15+
"""
16+
Structural interface for job board integration.
17+
18+
Callers depend on this Protocol rather than the concrete
19+
``JobBoardAdapter`` ABC. Any object providing these members
20+
satisfies the contract — inheritance is not required.
21+
"""
22+
23+
@property
24+
def board_name(self) -> str:
25+
"""Unique identifier string for this board."""
26+
...
27+
28+
@property
29+
def rate_limit_seconds(self) -> tuple[float, float]:
30+
"""(min, max) seconds to sleep between page loads."""
31+
...
32+
33+
async def authenticate(self, page: Page) -> None:
34+
"""Establish an authenticated session."""
35+
...
36+
37+
async def search(
38+
self,
39+
page: Page,
40+
query: str,
41+
max_pages: int,
42+
) -> list[JobListing]:
43+
"""Navigate search results and return shallow listings."""
44+
...
45+
46+
async def extract_detail(
47+
self,
48+
page: Page,
49+
listing: JobListing,
50+
) -> JobListing:
51+
"""Navigate to listing URL and populate full_text."""
52+
...

src/jobsearch_rag/adapters/registry.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
5+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
66

77
if TYPE_CHECKING:
8-
from jobsearch_rag.adapters.base import JobBoardAdapter
8+
from jobsearch_rag.adapters.ports import JobBoardPort
99

10-
_T = TypeVar("_T", bound="JobBoardAdapter")
10+
_T = TypeVar("_T", bound="JobBoardPort")
1111

1212

1313
class AdapterRegistry:
@@ -24,17 +24,17 @@ def board_name(self) -> str:
2424
...
2525
"""
2626

27-
_registry: ClassVar[dict[str, type[JobBoardAdapter]]] = {}
27+
_registry: ClassVar[dict[str, type[JobBoardPort]]] = {}
2828

2929
@classmethod
3030
def register(cls, adapter_class: type[_T]) -> type[_T]:
3131
"""Class decorator — registers an adapter by its ``board_name``."""
3232
instance = adapter_class.__new__(adapter_class)
33-
cls._registry[instance.board_name] = adapter_class # type: ignore[assignment] # _T is a subtype of JobBoardAdapter
33+
cls._registry[instance.board_name] = cast("type[JobBoardPort]", adapter_class)
3434
return adapter_class
3535

3636
@classmethod
37-
def get(cls, board_name: str, **kwargs: Any) -> JobBoardAdapter:
37+
def get(cls, board_name: str, **kwargs: Any) -> JobBoardPort:
3838
"""
3939
Return a new instance of the adapter registered under *board_name*.
4040

src/jobsearch_rag/adapters/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
if TYPE_CHECKING:
4444
from playwright.async_api import Browser, BrowserContext, Page, Playwright
4545

46-
from jobsearch_rag.adapters.base import JobBoardAdapter
46+
from jobsearch_rag.adapters.ports import JobBoardPort
4747

4848

4949
# ---------------------------------------------------------------------------
@@ -173,7 +173,7 @@ def _terminate_process(proc: subprocess.Popen[bytes]) -> None:
173173

174174

175175
async def throttle(
176-
adapter: JobBoardAdapter,
176+
adapter: JobBoardPort,
177177
rate_limit_range: tuple[float, float] | None = None,
178178
) -> float:
179179
"""

tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@
5050
from tests.constants import EMBED_FAKE as EMBED_FAKE # re-export for fixtures below
5151

5252
if TYPE_CHECKING:
53-
from collections.abc import Callable, Generator, Iterator
53+
from collections.abc import Callable, Generator, Iterator, Mapping
5454

55-
from jobsearch_rag.adapters.base import JobBoardAdapter
55+
from jobsearch_rag.adapters.ports import JobBoardPort
5656

5757
_PROJECT_OUTPUT = Path(__file__).resolve().parent.parent / "output"
5858

@@ -92,7 +92,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
9292

9393
@contextmanager
9494
def adapter_override(
95-
factories: dict[str, Callable[..., JobBoardAdapter]],
95+
factories: Mapping[str, Callable[..., JobBoardPort]],
9696
*,
9797
clear: bool = False,
9898
) -> Generator[None, None, None]:

tests/test_registry.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from jobsearch_rag.adapters.base import JobBoardAdapter, JobListing
2424
from jobsearch_rag.adapters.indeed import IndeedAdapter
2525
from jobsearch_rag.adapters.linkedin import LinkedInAdapter
26+
from jobsearch_rag.adapters.ports import JobBoardPort
2627
from jobsearch_rag.adapters.registry import AdapterRegistry
2728
from jobsearch_rag.adapters.weworkremotely import WeWorkRemotelyAdapter
2829
from tests.conftest import adapter_override
@@ -32,7 +33,7 @@
3233
# ---------------------------------------------------------------------------
3334

3435

35-
def _make_adapter_class(name: str) -> type[JobBoardAdapter]:
36+
def _make_adapter_class(name: str) -> type[JobBoardPort]:
3637
"""Dynamically create a concrete adapter class for testing."""
3738

3839
class _TestAdapter(JobBoardAdapter):
@@ -128,7 +129,7 @@ def test_registered_adapter_is_retrievable_by_board_name(self) -> None:
128129
adapter = AdapterRegistry.get("ziprecruiter")
129130

130131
# Then: returns a working adapter instance
131-
assert isinstance(adapter, JobBoardAdapter), "Should return a JobBoardAdapter instance"
132+
assert isinstance(adapter, JobBoardPort), "Should return a JobBoardPort instance"
132133
assert adapter.board_name == "ziprecruiter", "Board name should match"
133134

134135
def test_retrieving_unregistered_board_name_raises_value_error_with_name(self) -> None:
@@ -467,7 +468,7 @@ class TestStubAdapterContract:
467468
Never: Patch adapter internals
468469
"""
469470

470-
_STUB_BOARDS: typing.ClassVar[list[tuple[str, type[JobBoardAdapter]]]] = [
471+
_STUB_BOARDS: typing.ClassVar[list[tuple[str, type[JobBoardPort]]]] = [
471472
("linkedin", LinkedInAdapter),
472473
("indeed", IndeedAdapter),
473474
("weworkremotely", WeWorkRemotelyAdapter),
@@ -479,7 +480,7 @@ class TestStubAdapterContract:
479480
ids=["linkedin", "indeed", "weworkremotely"],
480481
)
481482
def test_stub_adapter_reports_correct_board_name(
482-
self, expected_name: str, adapter_cls: type[JobBoardAdapter]
483+
self, expected_name: str, adapter_cls: type[JobBoardPort]
483484
) -> None:
484485
"""
485486
GIVEN a stub adapter instance
@@ -521,7 +522,7 @@ def test_linkedin_adapter_overrides_rate_limit_for_aggressive_detection(
521522
)
522523
@pytest.mark.xfail(reason="Adapter not yet implemented")
523524
def test_stub_authenticate_completes_on_valid_session(
524-
self, _name: str, adapter_cls: type[JobBoardAdapter]
525+
self, _name: str, adapter_cls: type[JobBoardPort]
525526
) -> None:
526527
"""
527528
GIVEN a valid browser session
@@ -542,7 +543,7 @@ def test_stub_authenticate_completes_on_valid_session(
542543
)
543544
@pytest.mark.xfail(reason="Adapter not yet implemented")
544545
def test_stub_search_returns_list_of_job_listings(
545-
self, _name: str, adapter_cls: type[JobBoardAdapter]
546+
self, _name: str, adapter_cls: type[JobBoardPort]
546547
) -> None:
547548
"""
548549
GIVEN a valid browser session
@@ -568,7 +569,7 @@ def test_stub_search_returns_list_of_job_listings(
568569
)
569570
@pytest.mark.xfail(reason="Adapter not yet implemented")
570571
def test_stub_extract_detail_populates_full_text(
571-
self, _name: str, adapter_cls: type[JobBoardAdapter]
572+
self, _name: str, adapter_cls: type[JobBoardPort]
572573
) -> None:
573574
"""
574575
GIVEN a shallow listing without full_text

0 commit comments

Comments
 (0)