Full Code of VonStruddle/PyHunter for AI

master 45f24dfc9833 cached
22 files
68.4 KB
15.7k tokens
74 symbols
1 requests
Download .txt
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"
Download .txt
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
Download .txt
SYMBOL INDEX (74 symbols across 7 files)

FILE: pyhunter/_core.py
  function parse_data_payload (line 4) | def parse_data_payload(response, endpoint, method):

FILE: pyhunter/async_pyhunter.py
  class AsyncPyHunter (line 14) | class AsyncPyHunter:
    method __init__ (line 15) | def __init__(self, api_key, timeout=10, max_retries=2, retry_backoff=0.5,
    method base_params (line 26) | def base_params(self):
    method __aenter__ (line 29) | async def __aenter__(self):
    method __aexit__ (line 32) | async def __aexit__(self, exc_type, exc, tb):
    method aclose (line 35) | async def aclose(self):
    method _query_hunter (line 39) | async def _query_hunter(self, endpoint, params, request_type='get',
    method domain_search (line 93) | async def domain_search(self, domain=None, company=None, limit=None,
    method email_finder (line 122) | async def email_finder(self, domain=None, company=None, first_name=None,
    method email_verifier (line 153) | async def email_verifier(self, email, raw=False):
    method email_count (line 158) | async def email_count(self, domain=None, company=None, raw=False):
    method account_information (line 171) | async def account_information(self, raw=False):
    method logo (line 180) | async def logo(self, domain, raw=False):

FILE: pyhunter/exceptions.py
  class PyhunterError (line 1) | class PyhunterError(Exception):
  class MissingCompanyError (line 8) | class MissingCompanyError(PyhunterError):
  class MissingNameError (line 12) | class MissingNameError(PyhunterError):
  class HunterApiError (line 16) | class HunterApiError(PyhunterError):
    method __init__ (line 20) | def __init__(self, message='Hunter API request failed', status_code=None,
  class HunterTransportError (line 30) | class HunterTransportError(PyhunterError):
    method __init__ (line 34) | def __init__(self, message='Hunter API transport failed', endpoint=None,

FILE: pyhunter/pyhunter.py
  class PyHunter (line 14) | class PyHunter:
    method __init__ (line 15) | def __init__(self, api_key, timeout=10, max_retries=2, retry_backoff=0.5,
    method base_params (line 25) | def base_params(self):
    method _query_hunter (line 28) | def _query_hunter(self, endpoint, params, request_type='get',
    method domain_search (line 92) | def domain_search(self, domain=None, company=None, limit=None, offset=...
    method email_finder (line 163) | def email_finder(self, domain=None, company=None, first_name=None,
    method email_verifier (line 235) | def email_verifier(self, email, raw=False):
    method email_count (line 251) | def email_count(self, domain=None, company=None, raw=False):
    method account_information (line 282) | def account_information(self, raw=False):
    method email_enrichment (line 306) | def email_enrichment(self, email=None, linkedin_handle=None,
    method company_enrichment (line 341) | def company_enrichment(self, domain, clearbit_format=None, raw=False):
    method combined_enrichment (line 362) | def combined_enrichment(self, email, clearbit_format=None, raw=False):
    method discover (line 389) | def discover(self, query=None, organization=None, similar_to=None,
    method get_leads (line 476) | def get_leads(self, offset=None, limit=None, lead_list_id=None,
    method get_lead (line 552) | def get_lead(self, lead_id):
    method create_lead (line 566) | def create_lead(self, first_name, last_name, email=None, position=None,
    method upsert_lead (line 628) | def upsert_lead(self, email, first_name=None, last_name=None,
    method update_lead (line 690) | def update_lead(self, lead_id, first_name=None, last_name=None, email=...
    method delete_lead (line 754) | def delete_lead(self, lead_id):
    method get_leads_lists (line 772) | def get_leads_lists(self, offset=None, limit=None):
    method get_leads_list (line 793) | def get_leads_list(self, leads_list_id):
    method create_leads_list (line 810) | def create_leads_list(self, name, team_id=None):
    method update_leads_list (line 830) | def update_leads_list(self, leads_list_id, name, team_id=None):
    method delete_leads_list (line 853) | def delete_leads_list(self, leads_list_id):
    method get_leads_custom_attributes (line 874) | def get_leads_custom_attributes(self, raw=False):
    method get_leads_custom_attribute (line 888) | def get_leads_custom_attribute(self, attribute_id, raw=False):
    method create_leads_custom_attribute (line 906) | def create_leads_custom_attribute(self, label, raw=False):
    method update_leads_custom_attribute (line 924) | def update_leads_custom_attribute(self, attribute_id, label, raw=False):
    method delete_leads_custom_attribute (line 946) | def delete_leads_custom_attribute(self, attribute_id):
    method get_campaigns (line 967) | def get_campaigns(self, started=None, archived=None, limit=None,
    method get_campaign_recipients (line 999) | def get_campaign_recipients(self, campaign_id, limit=None, offset=None,
    method add_campaign_recipients (line 1027) | def add_campaign_recipients(self, campaign_id, emails=None,
    method cancel_campaign_recipients (line 1057) | def cancel_campaign_recipients(self, campaign_id, emails, raw=False):
    method start_campaign (line 1079) | def start_campaign(self, campaign_id, raw=False):
    method logo (line 1101) | def logo(self, domain, raw=False):

FILE: tests/test_async_client.py
  function test_async_domain_search_requires_domain_or_company (line 11) | async def test_async_domain_search_requires_domain_or_company():
  function test_async_email_finder_requires_name_when_no_linkedin (line 19) | async def test_async_email_finder_requires_name_when_no_linkedin():
  function test_async_email_finder_returns_tuple (line 27) | async def test_async_email_finder_returns_tuple():
  function test_async_account_information_adds_calls_left (line 37) | async def test_async_account_information_adds_calls_left():
  function test_async_invalid_payload_raises_api_error (line 48) | async def test_async_invalid_payload_raises_api_error():
  function test_async_logo_returns_content_bytes (line 60) | async def test_async_logo_returns_content_bytes():

FILE: tests/test_parity.py
  function test_sync_async_domain_search_parity (line 10) | async def test_sync_async_domain_search_parity():
  function test_sync_async_errors_match_for_missing_company (line 27) | async def test_sync_async_errors_match_for_missing_company():

FILE: tests/test_sync_client.py
  function _mock_response (line 10) | def _mock_response(payload, status_code=200):
  function test_domain_search_requires_domain_or_company (line 19) | def test_domain_search_requires_domain_or_company():
  function test_email_finder_requires_name_when_no_linkedin (line 25) | def test_email_finder_requires_name_when_no_linkedin():
  function test_domain_search_includes_offset_zero (line 31) | def test_domain_search_includes_offset_zero():
  function test_email_finder_returns_tuple (line 41) | def test_email_finder_returns_tuple():
  function test_account_information_adds_calls_left (line 49) | def test_account_information_adds_calls_left():
  function test_query_hunter_raises_hunter_api_error_on_invalid_payload (line 58) | def test_query_hunter_raises_hunter_api_error_on_invalid_payload():
  function test_query_hunter_wraps_http_error (line 67) | def test_query_hunter_wraps_http_error():
  function test_major_categories_call_query_hunter (line 81) | def test_major_categories_call_query_hunter():
  function test_logo_returns_content_bytes (line 93) | def test_logo_returns_content_bytes():
Condensed preview — 22 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (74K chars).
[
  {
    "path": ".github/dependabot.yml",
    "chars": 143,
    "preview": "version: 2\nupdates:\n- package-ecosystem: pip\n  directory: \"/\"\n  schedule:\n    interval: daily\n    time: \"04:00\"\n  open-p"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 652,
    "preview": "name: CI\n\non:\n  push:\n  pull_request:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        pyth"
  },
  {
    "path": ".gitignore",
    "chars": 1202,
    "preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packagi"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 609,
    "preview": "# Changelog\n\n## 2.1.0\n\n- Added `AsyncPyHunter` with initial async support for core endpoints:\n  - `domain_search`\n  - `e"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 689,
    "preview": "# Contributing to PyHunter\n\n## Local setup\n\n```bash\npython -m venv .venv\nsource .venv/bin/activate\npip install -e .[test"
  },
  {
    "path": "LICENSE",
    "chars": 1073,
    "preview": "MIT License\n\nCopyright (c) 2017 Quentin Durantay\n\nPermission is hereby granted, free of charge, to any person obtaining "
  },
  {
    "path": "Pipfile",
    "chars": 147,
    "preview": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nrequests = \"*\"\n\n[dev-packages]\npi"
  },
  {
    "path": "README.md",
    "chars": 7161,
    "preview": "[![PyPI version](https://badge.fury.io/py/pyhunter.svg)](https://badge.fury.io/py/pyhunter)\n\n# PyHunter\n\n## A Python wra"
  },
  {
    "path": "_config.yml",
    "chars": 27,
    "preview": "theme: jekyll-theme-minimal"
  },
  {
    "path": "pyhunter/__init__.py",
    "chars": 114,
    "preview": "from .async_pyhunter import AsyncPyHunter\nfrom .pyhunter import PyHunter\n\n__all__ = [\"PyHunter\", \"AsyncPyHunter\"]\n"
  },
  {
    "path": "pyhunter/_core.py",
    "chars": 565,
    "preview": "from .exceptions import HunterApiError\n\n\ndef parse_data_payload(response, endpoint, method):\n    try:\n        return res"
  },
  {
    "path": "pyhunter/async_pyhunter.py",
    "chars": 8179,
    "preview": "import asyncio\n\nimport httpx\n\nfrom ._core import parse_data_payload\nfrom .exceptions import (\n    HunterApiError,\n    Hu"
  },
  {
    "path": "pyhunter/exceptions.py",
    "chars": 996,
    "preview": "class PyhunterError(Exception):\n    \"\"\"\n    Generic exception class for the library\n    \"\"\"\n    pass\n\n\nclass MissingComp"
  },
  {
    "path": "pyhunter/pyhunter.py",
    "chars": 38913,
    "preview": "from time import sleep\n\nimport requests\n\nfrom ._core import parse_data_payload\nfrom .exceptions import (\n    HunterApiEr"
  },
  {
    "path": "pyproject.toml",
    "chars": 1715,
    "preview": "[build-system]\nrequires = [\"setuptools>=69\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"pyhunte"
  },
  {
    "path": "requirements-dev.txt",
    "chars": 606,
    "preview": "################################################################################\n# This requirements file has been autom"
  },
  {
    "path": "requirements.txt",
    "chars": 557,
    "preview": "################################################################################\n# This requirements file has been autom"
  },
  {
    "path": "setup.cfg",
    "chars": 40,
    "preview": "[metadata]\ndescription_file = README.md\n"
  },
  {
    "path": "setup.py",
    "chars": 69,
    "preview": "from setuptools import setup\n\nif __name__ == \"__main__\":\n    setup()\n"
  },
  {
    "path": "tests/test_async_client.py",
    "chars": 2332,
    "preview": "from unittest.mock import AsyncMock\n\nimport httpx\nimport pytest\n\nfrom pyhunter import AsyncPyHunter\nfrom pyhunter.except"
  },
  {
    "path": "tests/test_parity.py",
    "chars": 1048,
    "preview": "from unittest.mock import AsyncMock, Mock\n\nimport pytest\n\nfrom pyhunter import AsyncPyHunter, PyHunter\nfrom pyhunter.exc"
  },
  {
    "path": "tests/test_sync_client.py",
    "chars": 3199,
    "preview": "from unittest.mock import Mock\n\nimport pytest\nimport requests\n\nfrom pyhunter import PyHunter\nfrom pyhunter.exceptions im"
  }
]

About this extraction

This page contains the full source code of the VonStruddle/PyHunter GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 22 files (68.4 KB), approximately 15.7k tokens, and a symbol index with 74 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!