Repository: VonStruddle/PyHunter Branch: master Commit: 45f24dfc9833 Files: 22 Total size: 68.4 KB Directory structure: gitextract_cr90vkzv/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Pipfile ├── README.md ├── _config.yml ├── pyhunter/ │ ├── __init__.py │ ├── _core.py │ ├── async_pyhunter.py │ ├── exceptions.py │ └── pyhunter.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests/ ├── test_async_client.py ├── test_parity.py └── test_sync_client.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily time: "04:00" open-pull-requests-limit: 10 ================================================ FILE: .github/workflows/ci.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ PyHunter.egg-info .idea # VS Code .vscode/* ================================================ FILE: CHANGELOG.md ================================================ # 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`. ================================================ FILE: CONTRIBUTING.md ================================================ # 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/*` ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Quentin Durantay Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Pipfile ================================================ [[source]] url = "https://pypi.org/simple" verify_ssl = true name = "pypi" [packages] requests = "*" [dev-packages] pipenv-to-requirements = "*" ================================================ FILE: README.md ================================================ [![PyPI version](https://badge.fury.io/py/pyhunter.svg)](https://badge.fury.io/py/pyhunter) # PyHunter ## A Python wrapper for the Hunter.io v2 API ### Installation Requirements: * Python 3 (no Python 2 version) To install: ```bash pip install pyhunter ``` For async support: ```bash pip install "pyhunter[test]" ``` ### Usage Import the PyHunter and instantiate it: ```python from pyhunter import PyHunter ``` ```python 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 Search all email addresses for a given domain: ```python hunter.domain_search('instagram.com') ``` Pass the company name instead, along with optional filters: ```python hunter.domain_search( company='Instagram', limit=5, offset=2, emails_type='personal', seniority='senior,executive', department='sales,marketing', required_field='full_name', verification_status='valid', ) ``` --- ### Email Finder Find a specific person's email address: ```python email, confidence_score = hunter.email_finder('instagram.com', first_name='Kevin', last_name='Systrom') ``` Use the company name, full name, or LinkedIn handle instead: ```python hunter.email_finder(company='Instagram', full_name='Kevin Systrom', raw=True) hunter.email_finder(linkedin_handle='kevinsystrom', max_duration=15) ``` --- ### Email Verifier Check the deliverability of an email address: ```python hunter.email_verifier('kevin@instagram.com') ``` --- ### Email Count Check how many email addresses Hunter has for a given domain: ```python hunter.email_count('instagram.com') # or by company name hunter.email_count(company='Instagram') ``` --- ### Account Information Check your account information and remaining API calls: ```python hunter.account_information() ``` PyHunter adds a `calls['left']` field to the response with the number of API calls still available. --- **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 Look up all information about a person from their email or LinkedIn handle: ```python hunter.email_enrichment(email='kevin@instagram.com') hunter.email_enrichment(linkedin_handle='kevinsystrom') ``` Look up all information about a company from its domain: ```python hunter.company_enrichment('instagram.com') ``` Get combined person + company information in one call: ```python hunter.combined_enrichment('kevin@instagram.com') ``` All enrichment methods accept a `clearbit_format=True` parameter to return data in Clearbit-compatible format. --- ### Discover Find companies matching a set of criteria: ```python hunter.discover(query='Tech companies in Europe') hunter.discover( industry={'include': ['Technology', 'Software']}, headcount=['51-200', '201-500'], headquarters_location={'include': [{'country': 'France'}]}, limit=50, ) ``` --- ### Leads Get all leads: ```python hunter.get_leads() ``` Filter leads: ```python hunter.get_leads( offset=2, limit=10, lead_list_id=1, first_name='Kevin', last_name='Systrom', email='kevin@instagram.com', company='Instagram', phone_number='0102030405', twitter='kevin', position='CEO', sync_status='success', query='kevin', ) ``` Get a specific lead by id: ```python hunter.get_lead(42) ``` Create a lead: ```python hunter.create_lead( 'Quentin', 'Durantay', email='quentin.durantay@unicorn.io', position='CEO', company='Unicorn Consulting', company_size=10, confidence_score=100, website='unicornsaregreat.io', country_code='FR', postal_code=75000, source='theinternet.com', linkedin_url='www.linkedin.com/in/masteroftheuniverse', phone_number='0102030405', twitter='quentindty', notes='Met at a conference', leads_list_id=1, leads_list_ids=[1, 2, 3], ) ``` Create or update a lead by email (upsert): ```python hunter.upsert_lead('kevin@instagram.com', first_name='Kevin', last_name='Systrom') ``` Update a lead by id: ```python hunter.update_lead(1, position='CEO in chief', notes='Updated notes') ``` Delete a lead by id: ```python hunter.delete_lead(42) ``` --- ### Leads Lists Get all leads lists: ```python hunter.get_leads_lists() hunter.get_leads_lists(offset=3, limit=2) ``` Get a specific leads list by id: ```python hunter.get_leads_list(42) ``` Create a leads list: ```python hunter.create_leads_list('Ultra hot prospects') ``` Update a leads list: ```python hunter.update_leads_list(42, 'Ultra mega hot prospects') ``` Delete a leads list: ```python hunter.delete_leads_list(42) ``` --- ### Custom Attributes Manage custom attributes for your leads: ```python # List all custom attributes hunter.get_leads_custom_attributes() # Get a specific custom attribute hunter.get_leads_custom_attribute(1) # Create a new custom attribute hunter.create_leads_custom_attribute('Priority Level') # Update a custom attribute hunter.update_leads_custom_attribute(1, 'Deal Priority') # Delete a custom attribute hunter.delete_leads_custom_attribute(1) ``` --- ### Campaigns (Email Sequences) Manage your email sequences: ```python # List all campaigns hunter.get_campaigns() hunter.get_campaigns(started=True, limit=10) # Get recipients of a campaign hunter.get_campaign_recipients(42) # Add recipients to a campaign hunter.add_campaign_recipients(42, emails=['kevin@instagram.com', 'jack@twitter.com']) hunter.add_campaign_recipients(42, lead_ids=[1, 2, 3]) # Cancel scheduled emails to recipients hunter.cancel_campaign_recipients(42, emails=['kevin@instagram.com']) # Start a campaign 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). ### Contribute It's my first (ever) open-source library! So it can be improved. Feel very welcome to fork it and ask for pull requests if you find something buggy or lacking ;) ### Have a nice day scraping B2B emails with PyHunter! ================================================ FILE: _config.yml ================================================ theme: jekyll-theme-minimal ================================================ FILE: pyhunter/__init__.py ================================================ from .async_pyhunter import AsyncPyHunter from .pyhunter import PyHunter __all__ = ["PyHunter", "AsyncPyHunter"] ================================================ FILE: pyhunter/_core.py ================================================ 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 ================================================ FILE: pyhunter/async_pyhunter.py ================================================ 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 ================================================ FILE: pyhunter/exceptions.py ================================================ class PyhunterError(Exception): """ Generic exception class for the library """ pass class MissingCompanyError(PyhunterError): pass class MissingNameError(PyhunterError): pass class HunterApiError(PyhunterError): """ Represents something went wrong in the call to the Hunter API """ 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 ================================================ FILE: pyhunter/pyhunter.py ================================================ from time import sleep import requests from ._core import parse_data_payload from .exceptions import ( HunterApiError, HunterTransportError, MissingCompanyError, MissingNameError, ) class PyHunter: def __init__(self, api_key, timeout=10, max_retries=2, retry_backoff=0.5, session=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.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, 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 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, required_field=None, verification_status=None, raw=False): """ Return all the email addresses found for a given domain. :param domain: The domain on which to search for emails. Must be defined if company is not. :param company: The name of the company on which to search for emails. Must be defined if domain is not. :param limit: The maximum number of emails to give back. Default is 10. :param offset: The number of emails to skip. Default is 0. :param seniority: The seniority level of the owners of emails to give back. Can be 'junior', 'senior', 'executive' or a combination of them delimited by a comma. :param department: The department where the owners of the emails to give back work. Can be 'executive', 'it', 'finance', 'management', 'sales', 'legal', 'support', 'hr', 'marketing', 'communication' or a combination of them delimited by a comma. :param emails_type: The type of emails to give back. Can be one of 'personal' or 'generic'. :param required_field: Only return emails with this field present. Can be 'full_name', 'position', or 'phone_number'. :param verification_status: Only return emails with this verification status. Can be 'valid', 'accept_all', or 'unknown'. :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict, with email addresses found. """ 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 self._query_hunter(endpoint, params, raw=raw) 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): """ Find the email address of a person given its name and company's domain. :param domain: The domain of the company where the person works. Must be defined if company and linkedin_handle are not. :param company: The name of the company where the person works. Must be defined if domain and linkedin_handle are not. :param first_name: The first name of the person. Must be defined if full_name is not. :param last_name: The last name of the person. Must be defined if full_name is not. :param full_name: The full name of the person. Must be defined if first_name AND last_name are not. :param linkedin_handle: The LinkedIn handle of the person (e.g. 'johnsmith'). Can be used instead of domain/company + name. :param max_duration: Maximum duration of the request in seconds (3-20, default 10). :param raw: Gives back the entire response instead of just email and score. :return: email and score as a tuple. """ 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: if 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 = self._query_hunter(endpoint, params, raw=raw) if raw: return res email = res['email'] score = res['score'] return email, score def email_verifier(self, email, raw=False): """ Verify the deliverability of a given email address. :param email: The email address to check. :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict. """ params = {'email': email, 'api_key': self.api_key} endpoint = self.base_endpoint.format('email-verifier') return self._query_hunter(endpoint, params, raw=raw) def email_count(self, domain=None, company=None, raw=False): """ Give back the number of email addresses Hunter has for this domain/company. :param domain: The domain of the company where the person works. Must be defined if company is not. If both 'domain' and 'company' are given, the 'domain' will be used. :param company: The name of the company where the person works. Must be defined if domain is not. :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict. """ 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 self._query_hunter(endpoint, params, raw=raw) def account_information(self, raw=False): """ Gives the information about the account associated with the api_key. :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict. """ params = self.base_params endpoint = self.base_endpoint.format('account') res = self._query_hunter(endpoint, params, raw=raw) if raw: return res res['calls']['left'] = res['calls']['available'] - res['calls']['used'] return res # --------------------------------------------------------------------------- # Enrichment # --------------------------------------------------------------------------- def email_enrichment(self, email=None, linkedin_handle=None, clearbit_format=None, raw=False): """ Returns all information about a person given their email or LinkedIn handle. :param email: The person's email address. Must be defined if linkedin_handle is not. :param linkedin_handle: The person's LinkedIn profile handle. Must be defined if email is not. :param clearbit_format: If True, returns the response in Clearbit format. :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict. """ params = {'api_key': self.api_key} if email: params['email'] = email elif linkedin_handle: params['linkedin_handle'] = linkedin_handle else: raise MissingCompanyError( 'You must supply at least an email or a LinkedIn handle' ) if clearbit_format is not None: params['clearbit_format'] = clearbit_format endpoint = self.base_endpoint.format('people/find') return self._query_hunter(endpoint, params, raw=raw) def company_enrichment(self, domain, clearbit_format=None, raw=False): """ Returns all information about a company given its domain. :param domain: The company's domain name. Must be defined. :param clearbit_format: If True, returns the response in Clearbit format. :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict. """ params = {'domain': domain, 'api_key': self.api_key} if clearbit_format is not None: params['clearbit_format'] = clearbit_format endpoint = self.base_endpoint.format('companies/find') return self._query_hunter(endpoint, params, raw=raw) def combined_enrichment(self, email, clearbit_format=None, raw=False): """ Returns information about both a person and their company given the person's email address. :param email: The person's email address. Must be defined. :param clearbit_format: If True, returns the response in Clearbit format. :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict with 'person' and 'company' keys. """ params = {'email': email, 'api_key': self.api_key} if clearbit_format is not None: params['clearbit_format'] = clearbit_format endpoint = self.base_endpoint.format('combined/find') return self._query_hunter(endpoint, params, raw=raw) # --------------------------------------------------------------------------- # Discover # --------------------------------------------------------------------------- def discover(self, query=None, organization=None, similar_to=None, headquarters_location=None, industry=None, headcount=None, company_type=None, year_founded=None, keywords=None, technology=None, funding=None, limit=None, offset=None, raw=False): """ Returns companies matching a set of criteria. :param query: Natural language search query (e.g. 'Tech companies in Europe'). :param organization: Filter by domain and/or company name. Dict with optional 'domain' (list) and 'name' (list) keys. :param similar_to: Find companies similar to this one (Premium). Dict with 'domain' or 'name' key. :param headquarters_location: Filter by location. Dict with 'include' and/or 'exclude' lists, each containing dicts with optional 'continent', 'country', 'state', 'city' keys. :param industry: Filter by industry. Dict with 'include' and/or 'exclude' lists. :param headcount: Filter by company size. List of ranges such as '1-10', '11-50', '51-200', etc. :param company_type: Filter by company type. Dict with 'include' and/or 'exclude' lists. :param year_founded: Filter by founding year (Premium). Dict with optional 'from', 'to', 'include', 'exclude' keys. :param keywords: Filter by keywords (Premium). Dict with 'include' and/or 'exclude' lists and optional 'match' ('any'/'all'). :param technology: Filter by technologies used (Premium). Dict with 'include' and/or 'exclude' lists and optional 'match' ('any'/'all'). :param funding: Filter by funding (Premium). Dict with optional 'series', 'amount', and 'date' keys. :param limit: Maximum number of companies to return (default 100). :param offset: Number of companies to skip (default 0). :param raw: Gives back the entire response instead of just the 'data'. :return: Full payload of the query as a dict with a list of companies. """ params = {'api_key': self.api_key} payload = {} if query is not None: payload['query'] = query if organization is not None: payload['organization'] = organization if similar_to is not None: payload['similar_to'] = similar_to if headquarters_location is not None: payload['headquarters_location'] = headquarters_location if industry is not None: payload['industry'] = industry if headcount is not None: payload['headcount'] = headcount if company_type is not None: payload['company_type'] = company_type if year_founded is not None: payload['year_founded'] = year_founded if keywords is not None: payload['keywords'] = keywords if technology is not None: payload['technology'] = technology if funding is not None: payload['funding'] = funding if limit is not None: payload['limit'] = limit if offset is not None: payload['offset'] = offset endpoint = self.base_endpoint.format('discover') return self._query_hunter(endpoint, params, 'post', payload, raw=raw) # --------------------------------------------------------------------------- # Leads # --------------------------------------------------------------------------- def get_leads(self, offset=None, limit=None, lead_list_id=None, first_name=None, last_name=None, email=None, company=None, phone_number=None, twitter=None, position=None, industry=None, website=None, country_code=None, company_size=None, source=None, linkedin_url=None, sync_status=None, sending_status=None, verification_status=None, last_activity_at=None, last_contacted_at=None, custom_attributes=None, query=None): """ Gives back all the leads saved in your account. :param offset: Number of leads to skip. :param limit: Maximum number of leads to return (1-1000, default 20). :param lead_list_id: Id of a lead list to query leads on. :param first_name: First name to filter on. :param last_name: Last name to filter on. :param email: Email to filter on. :param company: Company to filter on. :param phone_number: Phone number to filter on. :param twitter: Twitter account to filter on. :param position: Job position to filter on. :param industry: Industry to filter on. :param website: Website to filter on. :param country_code: Country code to filter on. :param company_size: Company size to filter on. :param source: Lead source to filter on. :param linkedin_url: LinkedIn URL to filter on. :param sync_status: Filter by sync status ('pending', 'error', 'success'). :param sending_status: Filter by sending status. Can be a list of values ('clicked', 'opened', 'sent', 'pending', 'error', 'bounced', 'unsubscribed', 'replied'). :param verification_status: Filter by verification status. Can be a list of values ('accept_all', 'disposable', 'invalid', 'unknown', 'valid', 'webmail', 'pending'). :param last_activity_at: Filter by last activity date. :param last_contacted_at: Filter by last contacted date. :param custom_attributes: Dict of custom attribute slugs and values to filter on. :param query: Search query matching first_name, last_name, or email. :return: All leads found as a dict. """ args = locals() args_params = dict((key, value) for key, value in args.items() if value is not None) args_params.pop('self') params = self.base_params params.update(args_params) endpoint = self.base_endpoint.format('leads') return self._query_hunter(endpoint, params) def get_lead(self, lead_id): """ Get a specific lead saved on your account. :param lead_id: Id of the lead to search. Must be defined. :return: Lead found as a dict. """ params = self.base_params endpoint = self.base_endpoint.format('leads/' + str(lead_id)) return self._query_hunter(endpoint, params) def create_lead(self, first_name, last_name, email=None, position=None, company=None, company_industry=None, company_size=None, confidence_score=None, website=None, country_code=None, postal_code=None, source=None, linkedin_url=None, phone_number=None, twitter=None, notes=None, leads_list_id=None, leads_list_ids=None): """ Create a lead on your account. :param first_name: The first name of the lead to create. Must be defined. :param last_name: The last name of the lead to create. Must be defined. :param email: The email of the lead to create. :param position: The professional position of the lead to create. :param company: The company of the lead to create. :param company_industry: The type of industry of the company where the lead works. :param company_size: The size of the company where the lead works. :param confidence_score: The confidence score of the lead's email. :param website: The website of the lead's company. :param country_code: The country code of the lead's company. :param postal_code: The postal code of the lead's company. :param source: The source of the lead's email. :param linkedin_url: The URL of the lead's LinkedIn profile. :param phone_number: The phone number of the lead to create. :param twitter: The lead's Twitter account. :param notes: Notes about the lead. :param leads_list_id: The id of the leads list where to save the new lead. :param leads_list_ids: A list of leads list ids to assign the lead to multiple lists at once. :return: The newly created lead as a dict. """ args = locals() payload = dict((key, value) for key, value in args.items() if value is not None) payload.pop('self') params = self.base_params endpoint = self.base_endpoint.format('leads') return self._query_hunter(endpoint, params, 'post', payload) def upsert_lead(self, email, first_name=None, last_name=None, position=None, company=None, company_industry=None, company_size=None, confidence_score=None, website=None, country_code=None, postal_code=None, source=None, linkedin_url=None, phone_number=None, twitter=None, notes=None, leads_list_id=None, leads_list_ids=None): """ Create or update a lead based on their email address (upsert). If a lead with the given email already exists, it is updated; otherwise a new lead is created. :param email: The email of the lead. Must be defined. :param first_name: The first name of the lead. :param last_name: The last name of the lead. :param position: The professional position of the lead. :param company: The company of the lead. :param company_industry: The type of industry of the company where the lead works. :param company_size: The size of the company where the lead works. :param confidence_score: The confidence score of the lead's email. :param website: The website of the lead's company. :param country_code: The country code of the lead's company. :param postal_code: The postal code of the lead's company. :param source: The source of the lead's email. :param linkedin_url: The URL of the lead's LinkedIn profile. :param phone_number: The phone number of the lead. :param twitter: The lead's Twitter account. :param notes: Notes about the lead. :param leads_list_id: The id of the leads list to save the lead to. :param leads_list_ids: A list of leads list ids to assign the lead to multiple lists at once. :return: The created or updated lead as a dict. """ args = locals() payload = dict((key, value) for key, value in args.items() if value is not None) payload.pop('self') params = self.base_params endpoint = self.base_endpoint.format('leads') return self._query_hunter(endpoint, params, 'put', payload) def update_lead(self, lead_id, first_name=None, last_name=None, email=None, position=None, company=None, company_industry=None, company_size=None, confidence_score=None, website=None, country_code=None, postal_code=None, source=None, linkedin_url=None, phone_number=None, twitter=None, notes=None, leads_list_id=None, leads_list_ids=None): """ Update a lead on your account. :param lead_id: The id of the lead to update. Must be defined. :param first_name: The first name of the lead to update. :param last_name: The last name of the lead to update. :param email: The email of the lead to update. :param position: The professional position of the lead to update. :param company: The company of the lead to update. :param company_industry: The type of industry of the company where the lead works. :param company_size: The size of the company where the lead works. :param confidence_score: The confidence score of the lead's email. :param website: The website of the lead's company. :param country_code: The country code of the lead's company. :param postal_code: The postal code of the lead's company. :param source: The source of the lead's email. :param linkedin_url: The URL of the lead's LinkedIn profile. :param phone_number: The phone number of the lead to update. :param twitter: The lead's Twitter account. :param notes: Notes about the lead. :param leads_list_id: The id of the leads list where to save the lead. :param leads_list_ids: A list of leads list ids to assign the lead to multiple lists at once. :return: The newly updated lead as a dict. """ args = locals() payload = dict((key, value) for key, value in args.items() if value is not None) payload.pop('self') payload.pop('lead_id') params = self.base_params endpoint = self.base_endpoint.format('leads/' + str(lead_id)) return self._query_hunter(endpoint, params, 'put', payload) def delete_lead(self, lead_id): """ Delete a specific lead saved on your account. :param lead_id: Id of the lead to delete. Must be defined. :return: 204 Response. """ params = self.base_params endpoint = self.base_endpoint.format('leads/' + str(lead_id)) return self._query_hunter(endpoint, params, 'delete') # --------------------------------------------------------------------------- # Leads Lists # --------------------------------------------------------------------------- def get_leads_lists(self, offset=None, limit=None): """ Gives back all the leads lists saved on your account. :param offset: Number of lists to skip. :param limit: Maximum number of lists to return. :return: Leads lists found as a dict. """ params = self.base_params if offset: params['offset'] = offset if limit: params['limit'] = limit endpoint = self.base_endpoint.format('leads_lists') return self._query_hunter(endpoint, params) def get_leads_list(self, leads_list_id): """ Gives back a specific leads list saved on your account. :param leads_list_id: The id of the list to return. :return: Leads list found as a dict. """ params = self.base_params endpoint = self.base_endpoint.format( 'leads_lists/' + str(leads_list_id) ) return self._query_hunter(endpoint, params) def create_leads_list(self, name, team_id=None): """ Create a leads list. :param name: Name of the list to create. Must be defined. :param team_id: The id of the list to share this list with. :return: The created leads list as a dict. """ params = self.base_params payload = {'name': name} if team_id: payload['team_id'] = team_id endpoint = self.base_endpoint.format('leads_lists') return self._query_hunter(endpoint, params, 'post', payload) def update_leads_list(self, leads_list_id, name, team_id=None): """ Update a leads list. :param leads_list_id: The id of the list to update. :param name: Name of the list to update. Must be defined. :param team_id: The id of the list to share this list with. :return: 204 Response. """ params = self.base_params payload = {'name': name} if team_id: payload['team_id'] = team_id endpoint = self.base_endpoint.format( 'leads_lists/' + str(leads_list_id)) return self._query_hunter(endpoint, params, 'put', payload) def delete_leads_list(self, leads_list_id): """ Delete a leads list. :param leads_list_id: The id of the list to delete. :return: 204 Response. """ params = self.base_params endpoint = self.base_endpoint.format( 'leads_lists/' + str(leads_list_id) ) return self._query_hunter(endpoint, params, 'delete') # --------------------------------------------------------------------------- # Custom Attributes # --------------------------------------------------------------------------- def get_leads_custom_attributes(self, raw=False): """ Returns all custom attributes defined on your account. :param raw: Gives back the entire response instead of just the 'data'. :return: All custom attributes as a dict. """ params = self.base_params endpoint = self.base_endpoint.format('leads_custom_attributes') return self._query_hunter(endpoint, params, raw=raw) def get_leads_custom_attribute(self, attribute_id, raw=False): """ Returns a specific custom attribute. :param attribute_id: The id of the custom attribute. Must be defined. :param raw: Gives back the entire response instead of just the 'data'. :return: The custom attribute as a dict. """ params = self.base_params endpoint = self.base_endpoint.format( 'leads_custom_attributes/' + str(attribute_id) ) return self._query_hunter(endpoint, params, raw=raw) def create_leads_custom_attribute(self, label, raw=False): """ Creates a new custom attribute. :param label: The name of the custom attribute. Must be unique and defined. :param raw: Gives back the entire response instead of just the 'data'. :return: The newly created custom attribute as a dict. """ params = self.base_params payload = {'label': label} endpoint = self.base_endpoint.format('leads_custom_attributes') return self._query_hunter(endpoint, params, 'post', payload, raw=raw) def update_leads_custom_attribute(self, attribute_id, label, raw=False): """ Updates a custom attribute. :param attribute_id: The id of the custom attribute to update. Must be defined. :param label: The new name for the custom attribute. Must be defined. :param raw: Gives back the entire response instead of just the 'data'. :return: 204 Response. """ params = self.base_params payload = {'label': label} endpoint = self.base_endpoint.format( 'leads_custom_attributes/' + str(attribute_id) ) return self._query_hunter(endpoint, params, 'put', payload, raw=raw) def delete_leads_custom_attribute(self, attribute_id): """ Deletes a custom attribute. :param attribute_id: The id of the custom attribute to delete. Must be defined. :return: 204 Response. """ params = self.base_params endpoint = self.base_endpoint.format( 'leads_custom_attributes/' + str(attribute_id) ) return self._query_hunter(endpoint, params, 'delete') # --------------------------------------------------------------------------- # Campaigns (Email Sequences) # --------------------------------------------------------------------------- def get_campaigns(self, started=None, archived=None, limit=None, offset=None, raw=False): """ Returns all email sequences (campaigns) on your account. :param started: If True, only return started sequences. :param archived: If True, only return archived sequences. :param limit: Maximum number of sequences to return (1-100, default 20). :param offset: Number of sequences to skip. :param raw: Gives back the entire response instead of just the 'data'. :return: All campaigns as a dict. """ params = self.base_params if started is not None: params['started'] = started if archived is not None: params['archived'] = archived if limit is not None: params['limit'] = limit if offset is not None: params['offset'] = offset endpoint = self.base_endpoint.format('campaigns') return self._query_hunter(endpoint, params, raw=raw) def get_campaign_recipients(self, campaign_id, limit=None, offset=None, raw=False): """ Returns all recipients of a specific email sequence. :param campaign_id: The id of the campaign. Must be defined. :param limit: Maximum number of recipients to return (1-100, default 20). :param offset: Number of recipients to skip. :param raw: Gives back the entire response instead of just the 'data'. :return: All recipients as a dict. """ params = self.base_params if limit is not None: params['limit'] = limit if offset is not None: params['offset'] = offset endpoint = self.base_endpoint.format( 'campaigns/' + str(campaign_id) + '/recipients' ) return self._query_hunter(endpoint, params, raw=raw) def add_campaign_recipients(self, campaign_id, emails=None, lead_ids=None, raw=False): """ Adds recipients to an email sequence. :param campaign_id: The id of the campaign. Must be defined. :param emails: A single email string or a list of up to 50 email addresses to add. :param lead_ids: A list of up to 50 lead ids to add. :param raw: Gives back the entire response instead of just the 'data'. :return: Number of recipients added and any skipped recipients. """ params = self.base_params payload = {} if emails is not None: payload['emails'] = emails if lead_ids is not None: payload['lead_ids'] = lead_ids endpoint = self.base_endpoint.format( 'campaigns/' + str(campaign_id) + '/recipients' ) return self._query_hunter(endpoint, params, 'post', payload, raw=raw) def cancel_campaign_recipients(self, campaign_id, emails, raw=False): """ Cancels scheduled emails to recipients of a campaign. :param campaign_id: The id of the campaign. Must be defined. :param emails: A single email string or a list of up to 50 email addresses whose scheduled emails should be cancelled. :param raw: Gives back the entire response instead of just the 'data'. :return: Dict with cancelled recipients and message count. """ params = self.base_params payload = {'emails': emails} endpoint = self.base_endpoint.format( 'campaigns/' + str(campaign_id) + '/recipients' ) return self._query_hunter(endpoint, params, 'delete', payload, raw=raw) def start_campaign(self, campaign_id, raw=False): """ Starts a draft email sequence. :param campaign_id: The id of the campaign to start. Must be defined. :param raw: Gives back the entire response instead of just the 'data'. :return: Dict with a confirmation message and recipient count. """ params = self.base_params endpoint = self.base_endpoint.format( 'campaigns/' + str(campaign_id) + '/start' ) 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 ================================================ FILE: pyproject.toml ================================================ [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 ================================================ FILE: requirements-dev.txt ================================================ ################################################################################ # This requirements file has been automatically generated from `Pipfile` with # `pipenv-to-requirements` # # # This has been done to maintain backward compatibility with tools and services # that do not support `Pipfile` yet. # # Do NOT edit it directly, use `pipenv install [-d]` to modify `Pipfile` and # `Pipfile.lock` and then regenerate `requirements*.txt`. ################################################################################ pytest>=8.0.0 pytest-asyncio>=0.23.0 ruff>=0.5.0 mypy>=1.10.0 pytest-cov>=5.0.0 ================================================ FILE: requirements.txt ================================================ ################################################################################ # This requirements file has been automatically generated from `Pipfile` with # `pipenv-to-requirements` # # # This has been done to maintain backward compatibility with tools and services # that do not support `Pipfile` yet. # # Do NOT edit it directly, use `pipenv install [-d]` to modify `Pipfile` and # `Pipfile.lock` and then regenerate `requirements*.txt`. ################################################################################ requests>=2.20.0 httpx>=0.27.0 ================================================ FILE: setup.cfg ================================================ [metadata] description_file = README.md ================================================ FILE: setup.py ================================================ from setuptools import setup if __name__ == "__main__": setup() ================================================ FILE: tests/test_async_client.py ================================================ 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() ================================================ FILE: tests/test_parity.py ================================================ 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() ================================================ FILE: tests/test_sync_client.py ================================================ 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"