diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5543d57 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test,dev] + - name: Lint + run: ruff check . + - name: Type check + run: mypy pyhunter + - name: Test with coverage + run: pytest --cov=pyhunter --cov-report=term-missing --cov-fail-under=50 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1440519 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 2.1.0 + +- Added `AsyncPyHunter` with initial async support for core endpoints: + - `domain_search` + - `email_finder` + - `email_verifier` + - `email_count` + - `account_information` +- Hardened sync transport and error handling: + - timeout/retry/backoff options + - normalized `HunterApiError` and `HunterTransportError` + - safer per-call base params (no shared mutation across calls) +- Added pytest test suite including async tests and sync/async parity checks. +- Added GitHub Actions CI for lint, type checks, tests, and coverage threshold. +- Migrated project metadata to `pyproject.toml`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0b87ae5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to PyHunter + +## Local setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e .[test,dev] +``` + +## Quality checks + +```bash +ruff check . +mypy pyhunter +pytest --cov=pyhunter --cov-report=term-missing +``` + +## Pull requests + +- Keep the public sync API backward compatible unless the PR is explicitly marked breaking. +- Add tests for each bug fix or endpoint behavior change. +- Update `README.md` for user-visible changes. +- Add an entry to `CHANGELOG.md`. + +## Release checklist + +- Bump version in `pyproject.toml`. +- Ensure CI is green for all supported Python versions. +- Build and verify artifacts: + - `python -m build` + - `python -m twine check dist/*` diff --git a/README.md b/README.md index 6e11ef7..d179959 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ To install: pip install pyhunter ``` +For async support: + +```bash +pip install "pyhunter[test]" +``` + ### Usage Import the PyHunter and instantiate it: @@ -29,6 +35,36 @@ from pyhunter import PyHunter hunter = PyHunter('my_hunter_api_key') ``` +You can configure transport behavior: + +```python +hunter = PyHunter( + 'my_hunter_api_key', + timeout=10, + max_retries=2, + retry_backoff=0.5, +) +``` + +### Async Usage + +```python +from pyhunter import AsyncPyHunter + +async with AsyncPyHunter('my_hunter_api_key') as hunter: + result = await hunter.domain_search('instagram.com') +``` + +For long-lived clients: + +```python +hunter = AsyncPyHunter('my_hunter_api_key') +try: + data = await hunter.email_count('instagram.com') +finally: + await hunter.aclose() +``` + --- ### Domain Search @@ -110,6 +146,10 @@ PyHunter adds a `calls['left']` field to the response with the number of API cal **NOTE:** By default, all calls return the `data` element of the JSON response. Pass `raw=True` to get the full HTTP response object, including headers (e.g. `X-RateLimit-Remaining`) and the complete response body including `meta`. +Transport and HTTP failures raise typed exceptions: +- `HunterTransportError` for connectivity/timeouts +- `HunterApiError` for non-2xx and malformed API payloads + --- ### Enrichment @@ -313,6 +353,25 @@ hunter.start_campaign(42) --- +### Logos + +Get a company logo by domain (returns bytes): + +```python +logo_bytes = hunter.logo('stripe.com') +``` + +Async: + +```python +from pyhunter import AsyncPyHunter + +async with AsyncPyHunter('my_hunter_api_key') as async_hunter: + logo_bytes = await async_hunter.logo('stripe.com') +``` + +--- + ### Information If you find a bug or something is missing, feel free to open an issue or a pull request on [GitHub](https://github.com/VonStruddle/PyHunter). diff --git a/pyhunter/__init__.py b/pyhunter/__init__.py index aa08af0..48c37e4 100644 --- a/pyhunter/__init__.py +++ b/pyhunter/__init__.py @@ -1 +1,4 @@ +from .async_pyhunter import AsyncPyHunter from .pyhunter import PyHunter + +__all__ = ["PyHunter", "AsyncPyHunter"] diff --git a/pyhunter/_core.py b/pyhunter/_core.py new file mode 100644 index 0000000..5762ba2 --- /dev/null +++ b/pyhunter/_core.py @@ -0,0 +1,18 @@ +from .exceptions import HunterApiError + + +def parse_data_payload(response, endpoint, method): + try: + return response.json()["data"] + except (KeyError, ValueError) as exc: + try: + payload_data = response.json() + except ValueError: + payload_data = {"body": response.text} + raise HunterApiError( + message="Hunter API response format is invalid", + status_code=response.status_code, + payload=payload_data, + endpoint=endpoint, + method=method, + ) from exc diff --git a/pyhunter/async_pyhunter.py b/pyhunter/async_pyhunter.py new file mode 100644 index 0000000..6701391 --- /dev/null +++ b/pyhunter/async_pyhunter.py @@ -0,0 +1,212 @@ +import asyncio + +import httpx + +from ._core import parse_data_payload +from .exceptions import ( + HunterApiError, + HunterTransportError, + MissingCompanyError, + MissingNameError, +) + + +class AsyncPyHunter: + def __init__(self, api_key, timeout=10, max_retries=2, retry_backoff=0.5, + client=None): + self.api_key = api_key + self.base_endpoint = 'https://api.hunter.io/v2/{}' + self.timeout = timeout + self.max_retries = max_retries + self.retry_backoff = retry_backoff + self.client = client or httpx.AsyncClient(timeout=timeout) + self._owns_client = client is None + + @property + def base_params(self): + return {'api_key': self.api_key} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.aclose() + + async def aclose(self): + if self._owns_client: + await self.client.aclose() + + async def _query_hunter(self, endpoint, params, request_type='get', + payload=None, headers=None, raw=False): + request_kwargs = dict(params=params, json=payload, headers=headers) + attempt = 0 + while attempt <= self.max_retries: + try: + res = await self.client.request( + request_type.upper(), + endpoint, + **request_kwargs + ) + res.raise_for_status() + break + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code if exc.response else None + should_retry = status_code in (429, 500, 502, 503, 504) + if should_retry and attempt < self.max_retries: + await asyncio.sleep(self.retry_backoff * (2 ** attempt)) + attempt += 1 + continue + payload_data = None + if exc.response is not None: + try: + payload_data = exc.response.json() + except ValueError: + payload_data = {'body': exc.response.text} + raise HunterApiError( + message='Hunter API returned an error response', + status_code=status_code, + error=( + payload_data.get('errors') + if isinstance(payload_data, dict) + else None + ), + payload=payload_data, + endpoint=endpoint, + method=request_type.upper(), + ) from exc + except httpx.HTTPError as exc: + if attempt < self.max_retries: + await asyncio.sleep(self.retry_backoff * (2 ** attempt)) + attempt += 1 + continue + raise HunterTransportError( + message='Failed to reach Hunter API', + endpoint=endpoint, + method=request_type.upper(), + ) from exc + + if raw: + return res + + return parse_data_payload(res, endpoint, request_type.upper()) + + async def domain_search(self, domain=None, company=None, limit=None, + offset=None, seniority=None, department=None, + emails_type=None, required_field=None, + verification_status=None, raw=False): + if domain: + params = {'domain': domain, 'api_key': self.api_key} + elif company: + params = {'company': company, 'api_key': self.api_key} + else: + raise MissingCompanyError( + 'You must supply at least a domain name or a company name' + ) + if limit is not None: + params['limit'] = limit + if offset is not None: + params['offset'] = offset + if seniority: + params['seniority'] = seniority + if department: + params['department'] = department + if emails_type: + params['type'] = emails_type + if required_field: + params['required_field'] = required_field + if verification_status: + params['verification_status'] = verification_status + endpoint = self.base_endpoint.format('domain-search') + return await self._query_hunter(endpoint, params, raw=raw) + + async def email_finder(self, domain=None, company=None, first_name=None, + last_name=None, full_name=None, + linkedin_handle=None, max_duration=None, raw=False): + params = self.base_params + if not domain and not company and not linkedin_handle: + raise MissingCompanyError( + 'You must supply at least a domain name, a company name, or a LinkedIn handle' + ) + if domain: + params['domain'] = domain + elif company: + params['company'] = company + if linkedin_handle: + params['linkedin_handle'] = linkedin_handle + if not linkedin_handle and not (first_name and last_name) and not full_name: + raise MissingNameError( + 'You must supply a first name AND a last name OR a full name' + ) + if first_name and last_name: + params['first_name'] = first_name + params['last_name'] = last_name + elif full_name: + params['full_name'] = full_name + if max_duration: + params['max_duration'] = max_duration + endpoint = self.base_endpoint.format('email-finder') + res = await self._query_hunter(endpoint, params, raw=raw) + if raw: + return res + return res['email'], res['score'] + + async def email_verifier(self, email, raw=False): + params = {'email': email, 'api_key': self.api_key} + endpoint = self.base_endpoint.format('email-verifier') + return await self._query_hunter(endpoint, params, raw=raw) + + async def email_count(self, domain=None, company=None, raw=False): + params = self.base_params + if not domain and not company: + raise MissingCompanyError( + 'You must supply at least a domain name or a company name' + ) + if domain: + params['domain'] = domain + elif company: + params['company'] = company + endpoint = self.base_endpoint.format('email-count') + return await self._query_hunter(endpoint, params, raw=raw) + + async def account_information(self, raw=False): + params = self.base_params + endpoint = self.base_endpoint.format('account') + res = await self._query_hunter(endpoint, params, raw=raw) + if raw: + return res + res['calls']['left'] = res['calls']['available'] - res['calls']['used'] + return res + + async def logo(self, domain, raw=False): + """ + Returns company logo bytes for a given domain. + + :param domain: The company's domain name. Must be defined. + + :param raw: Gives back the entire response instead of just image bytes. + + :return: Binary logo content (bytes) or a raw response object. + """ + endpoint = 'https://logos.hunter.io/{}'.format(domain) + try: + res = await self.client.get(endpoint) + res.raise_for_status() + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code if exc.response else None + raise HunterApiError( + message='Hunter logos endpoint returned an error response', + status_code=status_code, + payload={'body': exc.response.text} if exc.response is not None else None, + endpoint=endpoint, + method='GET', + ) from exc + except httpx.HTTPError as exc: + raise HunterTransportError( + message='Failed to reach Hunter logos endpoint', + endpoint=endpoint, + method='GET', + ) from exc + + if raw: + return res + return res.content diff --git a/pyhunter/exceptions.py b/pyhunter/exceptions.py index 3a7bb29..3ec0972 100644 --- a/pyhunter/exceptions.py +++ b/pyhunter/exceptions.py @@ -17,4 +17,22 @@ class HunterApiError(PyhunterError): """ Represents something went wrong in the call to the Hunter API """ - pass + def __init__(self, message='Hunter API request failed', status_code=None, + error=None, payload=None, endpoint=None, method=None): + super().__init__(message) + self.status_code = status_code + self.error = error + self.payload = payload + self.endpoint = endpoint + self.method = method + + +class HunterTransportError(PyhunterError): + """ + Represents transport-level errors while calling Hunter API + """ + def __init__(self, message='Hunter API transport failed', endpoint=None, + method=None): + super().__init__(message) + self.endpoint = endpoint + self.method = method diff --git a/pyhunter/pyhunter.py b/pyhunter/pyhunter.py index 4b6283a..d7b15a2 100644 --- a/pyhunter/pyhunter.py +++ b/pyhunter/pyhunter.py @@ -1,30 +1,93 @@ +from time import sleep + import requests -from .exceptions import MissingCompanyError, MissingNameError, HunterApiError +from ._core import parse_data_payload +from .exceptions import ( + HunterApiError, + HunterTransportError, + MissingCompanyError, + MissingNameError, +) class PyHunter: - def __init__(self, api_key): + def __init__(self, api_key, timeout=10, max_retries=2, retry_backoff=0.5, + session=None): self.api_key = api_key - self.base_params = {'api_key': api_key} self.base_endpoint = 'https://api.hunter.io/v2/{}' + self.timeout = timeout + self.max_retries = max_retries + self.retry_backoff = retry_backoff + self.session = session or requests.Session() + + @property + def base_params(self): + return {'api_key': self.api_key} def _query_hunter(self, endpoint, params, request_type='get', payload=None, headers=None, raw=False): - - request_kwargs = dict(params=params, json=payload, headers=headers) - res = getattr(requests, request_type)(endpoint, **request_kwargs) - res.raise_for_status() + request_kwargs = dict( + params=params, + json=payload, + headers=headers, + timeout=self.timeout, + ) + attempt = 0 + last_error = None + while attempt <= self.max_retries: + try: + res = getattr(self.session, request_type)(endpoint, **request_kwargs) + res.raise_for_status() + break + except requests.exceptions.HTTPError as exc: + status_code = exc.response.status_code if exc.response else None + should_retry = status_code in (429, 500, 502, 503, 504) + if should_retry and attempt < self.max_retries: + sleep(self.retry_backoff * (2 ** attempt)) + attempt += 1 + continue + message = 'Hunter API returned an error response' + payload_data = None + if exc.response is not None: + try: + payload_data = exc.response.json() + except ValueError: + payload_data = {'body': exc.response.text} + raise HunterApiError( + message=message, + status_code=status_code, + error=( + payload_data.get('errors') + if isinstance(payload_data, dict) + else None + ), + payload=payload_data, + endpoint=endpoint, + method=request_type.upper(), + ) from exc + except requests.exceptions.RequestException as exc: + last_error = exc + if attempt < self.max_retries: + sleep(self.retry_backoff * (2 ** attempt)) + attempt += 1 + continue + raise HunterTransportError( + message='Failed to reach Hunter API', + endpoint=endpoint, + method=request_type.upper(), + ) from exc + else: + raise HunterTransportError( + message='Failed to reach Hunter API', + endpoint=endpoint, + method=request_type.upper(), + ) from last_error if raw: return res - try: - data = res.json()['data'] - except KeyError: - raise HunterApiError(res.json()) - - return data + return parse_data_payload(res, endpoint, request_type.upper()) def domain_search(self, domain=None, company=None, limit=None, offset=None, seniority=None, department=None, emails_type=None, @@ -72,10 +135,10 @@ def domain_search(self, domain=None, company=None, limit=None, offset=None, 'You must supply at least a domain name or a company name' ) - if limit: + if limit is not None: params['limit'] = limit - if offset: + if offset is not None: params['offset'] = offset if seniority: @@ -1030,3 +1093,41 @@ def start_campaign(self, campaign_id, raw=False): ) return self._query_hunter(endpoint, params, 'post', raw=raw) + + # --------------------------------------------------------------------------- + # Logos + # --------------------------------------------------------------------------- + + def logo(self, domain, raw=False): + """ + Returns company logo bytes for a given domain. + + :param domain: The company's domain name. Must be defined. + + :param raw: Gives back the entire response instead of just image bytes. + + :return: Binary logo content (bytes) or a raw response object. + """ + endpoint = 'https://logos.hunter.io/{}'.format(domain) + try: + res = self.session.get(endpoint, timeout=self.timeout) + res.raise_for_status() + except requests.exceptions.HTTPError as exc: + status_code = exc.response.status_code if exc.response else None + raise HunterApiError( + message='Hunter logos endpoint returned an error response', + status_code=status_code, + payload={'body': exc.response.text} if exc.response is not None else None, + endpoint=endpoint, + method='GET', + ) from exc + except requests.exceptions.RequestException as exc: + raise HunterTransportError( + message='Failed to reach Hunter logos endpoint', + endpoint=endpoint, + method='GET', + ) from exc + + if raw: + return res + return res.content diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fdad7f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyhunter" +version = "2.1.0" +description = "An (unofficial) Python wrapper for the Hunter.io API" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +authors = [ + { name = "Quentin Durantay", email = "quentin.durantay@gmail.com" } +] +keywords = ["hunter", "hunter.io", "lead generation", "lead enrichment"] +classifiers = [ + "Development Status :: 4 - Beta", + "Natural Language :: English", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Utilities", +] +dependencies = [ + "requests>=2.20.0", + "httpx>=0.27.0", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=5.0.0", +] +dev = [ + "mypy>=1.10.0", + "ruff>=0.5.0", + "build>=1.2.1", + "twine>=5.1.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=5.0.0", +] + +[project.urls] +Homepage = "https://github.com/VonStruddle/PyHunter" +Repository = "https://github.com/VonStruddle/PyHunter" + +[tool.setuptools] +packages = ["pyhunter"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff] +line-length = 120 +target-version = "py38" + +[tool.ruff.lint] +select = ["E", "F", "I"] + +[tool.mypy] +python_version = "3.9" +ignore_missing_imports = true +warn_unused_configs = true diff --git a/requirements-dev.txt b/requirements-dev.txt index a9d71da..3526245 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,4 +10,8 @@ # `Pipfile.lock` and then regenerate `requirements*.txt`. ################################################################################ -pipenv-to-requirements +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +ruff>=0.5.0 +mypy>=1.10.0 +pytest-cov>=5.0.0 diff --git a/requirements.txt b/requirements.txt index cebc392..7ffb3d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ # `Pipfile.lock` and then regenerate `requirements*.txt`. ################################################################################ -requests +requests>=2.20.0 +httpx>=0.27.0 diff --git a/setup.py b/setup.py index 4572f3f..7f1a176 100644 --- a/setup.py +++ b/setup.py @@ -1,34 +1,4 @@ from setuptools import setup -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name='pyhunter', - packages=['pyhunter'], - version='2.0', - description='An (unofficial) Python wrapper for the Hunter.io API', - long_description=long_description, - long_description_content_type='text/markdown', - author='Quentin Durantay', - author_email='quentin.durantay@gmail.com', - url='https://github.com/VonStruddle/PyHunter', - download_url='https://github.com/VonStruddle/PyHunter/archive/121.tar.gz', - license='MIT', - install_requires=['requests>=2.20.0'], - keywords=['hunter', 'hunter.io', 'lead generation', 'lead enrichment'], - classifiers=[ - 'Development Status :: 4 - Beta', - 'Natural Language :: English', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Topic :: Utilities' - ], -) +if __name__ == "__main__": + setup() diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..4932bba --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,72 @@ +from unittest.mock import AsyncMock + +import httpx +import pytest + +from pyhunter import AsyncPyHunter +from pyhunter.exceptions import HunterApiError, MissingCompanyError, MissingNameError + + +@pytest.mark.asyncio +async def test_async_domain_search_requires_domain_or_company(): + hunter = AsyncPyHunter("key") + with pytest.raises(MissingCompanyError): + await hunter.domain_search() + await hunter.aclose() + + +@pytest.mark.asyncio +async def test_async_email_finder_requires_name_when_no_linkedin(): + hunter = AsyncPyHunter("key") + with pytest.raises(MissingNameError): + await hunter.email_finder(domain="example.com") + await hunter.aclose() + + +@pytest.mark.asyncio +async def test_async_email_finder_returns_tuple(): + hunter = AsyncPyHunter("key") + hunter._query_hunter = AsyncMock(return_value={"email": "a@b.com", "score": 99}) + email, score = await hunter.email_finder(domain="example.com", full_name="A B") + assert email == "a@b.com" + assert score == 99 + await hunter.aclose() + + +@pytest.mark.asyncio +async def test_async_account_information_adds_calls_left(): + hunter = AsyncPyHunter("key") + hunter._query_hunter = AsyncMock( + return_value={"calls": {"available": 200, "used": 20}} + ) + data = await hunter.account_information() + assert data["calls"]["left"] == 180 + await hunter.aclose() + + +@pytest.mark.asyncio +async def test_async_invalid_payload_raises_api_error(): + transport = httpx.MockTransport( + lambda _: httpx.Response(200, json={"meta": {"ok": True}}) + ) + client = httpx.AsyncClient(transport=transport, timeout=5) + hunter = AsyncPyHunter("key", client=client, max_retries=0) + with pytest.raises(HunterApiError): + await hunter._query_hunter("https://api.hunter.io/v2/account", {"api_key": "key"}) + await hunter.aclose() + + +@pytest.mark.asyncio +async def test_async_logo_returns_content_bytes(): + transport = httpx.MockTransport( + lambda _: httpx.Response( + 200, + content=b"binary-logo", + headers={"content-type": "image/png"}, + ) + ) + client = httpx.AsyncClient(transport=transport, timeout=5) + hunter = AsyncPyHunter("key", client=client, max_retries=0) + content = await hunter.logo("stripe.com") + assert content == b"binary-logo" + await hunter.aclose() diff --git a/tests/test_parity.py b/tests/test_parity.py new file mode 100644 index 0000000..f7fc77e --- /dev/null +++ b/tests/test_parity.py @@ -0,0 +1,36 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from pyhunter import AsyncPyHunter, PyHunter +from pyhunter.exceptions import MissingCompanyError + + +@pytest.mark.asyncio +async def test_sync_async_domain_search_parity(): + expected = {"domain": "example.com", "emails": []} + + sync_client = PyHunter("key") + sync_client._query_hunter = Mock(return_value=expected) + + async_client = AsyncPyHunter("key") + async_client._query_hunter = AsyncMock(return_value=expected) + + sync_data = sync_client.domain_search(domain="example.com") + async_data = await async_client.domain_search(domain="example.com") + + assert sync_data == async_data + await async_client.aclose() + + +@pytest.mark.asyncio +async def test_sync_async_errors_match_for_missing_company(): + sync_client = PyHunter("key") + async_client = AsyncPyHunter("key") + + with pytest.raises(MissingCompanyError): + sync_client.email_count() + with pytest.raises(MissingCompanyError): + await async_client.email_count() + + await async_client.aclose() diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py new file mode 100644 index 0000000..1505aaa --- /dev/null +++ b/tests/test_sync_client.py @@ -0,0 +1,103 @@ +from unittest.mock import Mock + +import pytest +import requests + +from pyhunter import PyHunter +from pyhunter.exceptions import HunterApiError, MissingCompanyError, MissingNameError + + +def _mock_response(payload, status_code=200): + response = Mock() + response.status_code = status_code + response.json.return_value = payload + response.text = str(payload) + response.raise_for_status.return_value = None + return response + + +def test_domain_search_requires_domain_or_company(): + hunter = PyHunter("key") + with pytest.raises(MissingCompanyError): + hunter.domain_search() + + +def test_email_finder_requires_name_when_no_linkedin(): + hunter = PyHunter("key") + with pytest.raises(MissingNameError): + hunter.email_finder(domain="example.com") + + +def test_domain_search_includes_offset_zero(): + hunter = PyHunter("key") + hunter._query_hunter = Mock(return_value={"emails": []}) + hunter.domain_search(domain="example.com", offset=0, limit=0) + args = hunter._query_hunter.call_args[0] + params = args[1] + assert params["offset"] == 0 + assert params["limit"] == 0 + + +def test_email_finder_returns_tuple(): + hunter = PyHunter("key") + hunter._query_hunter = Mock(return_value={"email": "a@b.com", "score": 92}) + email, score = hunter.email_finder(domain="example.com", full_name="A B") + assert email == "a@b.com" + assert score == 92 + + +def test_account_information_adds_calls_left(): + hunter = PyHunter("key") + hunter._query_hunter = Mock( + return_value={"calls": {"available": 100, "used": 10}} + ) + data = hunter.account_information() + assert data["calls"]["left"] == 90 + + +def test_query_hunter_raises_hunter_api_error_on_invalid_payload(): + hunter = PyHunter("key") + bad = _mock_response({"meta": {}}, 200) + hunter.session = Mock() + hunter.session.get.return_value = bad + with pytest.raises(HunterApiError): + hunter._query_hunter("https://api.hunter.io/v2/account", {"api_key": "key"}) + + +def test_query_hunter_wraps_http_error(): + hunter = PyHunter("key", max_retries=0) + err_response = _mock_response({"errors": [{"id": "bad"}]}, status_code=400) + err_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + response=err_response + ) + hunter.session = Mock() + hunter.session.get.return_value = err_response + + with pytest.raises(HunterApiError) as exc: + hunter._query_hunter("https://api.hunter.io/v2/account", {"api_key": "key"}) + assert exc.value.status_code == 400 + + +def test_major_categories_call_query_hunter(): + hunter = PyHunter("key") + hunter._query_hunter = Mock(return_value={"ok": True}) + + hunter.email_enrichment(email="a@b.com") + hunter.discover(query="tech") + hunter.get_leads(limit=5) + hunter.get_campaigns(limit=5) + + assert hunter._query_hunter.call_count == 4 + + +def test_logo_returns_content_bytes(): + hunter = PyHunter("key") + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.content = b"binary-logo" + hunter.session = Mock() + hunter.session.get.return_value = mock_response + + logo = hunter.logo("stripe.com") + + assert logo == b"binary-logo"