[
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: pip\n  directory: \"/\"\n  schedule:\n    interval: daily\n    time: \"04:00\"\n  open-pull-requests-limit: 10\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n  pull_request:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.8\", \"3.11\", \"3.13\"]\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: ${{ matrix.python-version }}\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -e .[test,dev]\n      - name: Lint\n        run: ruff check .\n      - name: Type check\n        run: mypy pyhunter\n      - name: Test with coverage\n        run: pytest --cov=pyhunter --cov-report=term-missing --cov-fail-under=50\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nenv/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n.hypothesis/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# pyenv\n.python-version\n\n# celery beat schedule file\ncelerybeat-schedule\n\n# SageMath parsed files\n*.sage.py\n\n# dotenv\n.env\n\n# virtualenv\n.venv\nvenv/\nENV/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\nPyHunter.egg-info\n.idea\n\n# VS Code\n.vscode/*\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## 2.1.0\n\n- Added `AsyncPyHunter` with initial async support for core endpoints:\n  - `domain_search`\n  - `email_finder`\n  - `email_verifier`\n  - `email_count`\n  - `account_information`\n- Hardened sync transport and error handling:\n  - timeout/retry/backoff options\n  - normalized `HunterApiError` and `HunterTransportError`\n  - safer per-call base params (no shared mutation across calls)\n- Added pytest test suite including async tests and sync/async parity checks.\n- Added GitHub Actions CI for lint, type checks, tests, and coverage threshold.\n- Migrated project metadata to `pyproject.toml`.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to PyHunter\n\n## Local setup\n\n```bash\npython -m venv .venv\nsource .venv/bin/activate\npip install -e .[test,dev]\n```\n\n## Quality checks\n\n```bash\nruff check .\nmypy pyhunter\npytest --cov=pyhunter --cov-report=term-missing\n```\n\n## Pull requests\n\n- Keep the public sync API backward compatible unless the PR is explicitly marked breaking.\n- Add tests for each bug fix or endpoint behavior change.\n- Update `README.md` for user-visible changes.\n- Add an entry to `CHANGELOG.md`.\n\n## Release checklist\n\n- Bump version in `pyproject.toml`.\n- Ensure CI is green for all supported Python versions.\n- Build and verify artifacts:\n  - `python -m build`\n  - `python -m twine check dist/*`\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Quentin Durantay\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Pipfile",
    "content": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\nrequests = \"*\"\n\n[dev-packages]\npipenv-to-requirements = \"*\"\n"
  },
  {
    "path": "README.md",
    "content": "[![PyPI version](https://badge.fury.io/py/pyhunter.svg)](https://badge.fury.io/py/pyhunter)\n\n# PyHunter\n\n## A Python wrapper for the Hunter.io v2 API\n\n### Installation\n\nRequirements:\n\n* Python 3 (no Python 2 version)\n\n\nTo install:\n\n```bash\npip install pyhunter\n```\n\nFor async support:\n\n```bash\npip install \"pyhunter[test]\"\n```\n\n### Usage\n\nImport the PyHunter and instantiate it:\n\n```python\nfrom pyhunter import PyHunter\n```\n\n```python\nhunter = PyHunter('my_hunter_api_key')\n```\n\nYou can configure transport behavior:\n\n```python\nhunter = PyHunter(\n    'my_hunter_api_key',\n    timeout=10,\n    max_retries=2,\n    retry_backoff=0.5,\n)\n```\n\n### Async Usage\n\n```python\nfrom pyhunter import AsyncPyHunter\n\nasync with AsyncPyHunter('my_hunter_api_key') as hunter:\n    result = await hunter.domain_search('instagram.com')\n```\n\nFor long-lived clients:\n\n```python\nhunter = AsyncPyHunter('my_hunter_api_key')\ntry:\n    data = await hunter.email_count('instagram.com')\nfinally:\n    await hunter.aclose()\n```\n\n---\n\n### Domain Search\n\nSearch all email addresses for a given domain:\n\n```python\nhunter.domain_search('instagram.com')\n```\n\nPass the company name instead, along with optional filters:\n\n```python\nhunter.domain_search(\n    company='Instagram',\n    limit=5,\n    offset=2,\n    emails_type='personal',\n    seniority='senior,executive',\n    department='sales,marketing',\n    required_field='full_name',\n    verification_status='valid',\n)\n```\n\n---\n\n### Email Finder\n\nFind a specific person's email address:\n\n```python\nemail, confidence_score = hunter.email_finder('instagram.com', first_name='Kevin', last_name='Systrom')\n```\n\nUse the company name, full name, or LinkedIn handle instead:\n\n```python\nhunter.email_finder(company='Instagram', full_name='Kevin Systrom', raw=True)\n\nhunter.email_finder(linkedin_handle='kevinsystrom', max_duration=15)\n```\n\n---\n\n### Email Verifier\n\nCheck the deliverability of an email address:\n\n```python\nhunter.email_verifier('kevin@instagram.com')\n```\n\n---\n\n### Email Count\n\nCheck how many email addresses Hunter has for a given domain:\n\n```python\nhunter.email_count('instagram.com')\n# or by company name\nhunter.email_count(company='Instagram')\n```\n\n---\n\n### Account Information\n\nCheck your account information and remaining API calls:\n\n```python\nhunter.account_information()\n```\n\nPyHunter adds a `calls['left']` field to the response with the number of API calls still available.\n\n---\n\n**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`.\n\nTransport and HTTP failures raise typed exceptions:\n- `HunterTransportError` for connectivity/timeouts\n- `HunterApiError` for non-2xx and malformed API payloads\n\n---\n\n### Enrichment\n\nLook up all information about a person from their email or LinkedIn handle:\n\n```python\nhunter.email_enrichment(email='kevin@instagram.com')\nhunter.email_enrichment(linkedin_handle='kevinsystrom')\n```\n\nLook up all information about a company from its domain:\n\n```python\nhunter.company_enrichment('instagram.com')\n```\n\nGet combined person + company information in one call:\n\n```python\nhunter.combined_enrichment('kevin@instagram.com')\n```\n\nAll enrichment methods accept a `clearbit_format=True` parameter to return data in Clearbit-compatible format.\n\n---\n\n### Discover\n\nFind companies matching a set of criteria:\n\n```python\nhunter.discover(query='Tech companies in Europe')\n\nhunter.discover(\n    industry={'include': ['Technology', 'Software']},\n    headcount=['51-200', '201-500'],\n    headquarters_location={'include': [{'country': 'France'}]},\n    limit=50,\n)\n```\n\n---\n\n### Leads\n\nGet all leads:\n\n```python\nhunter.get_leads()\n```\n\nFilter leads:\n\n```python\nhunter.get_leads(\n    offset=2,\n    limit=10,\n    lead_list_id=1,\n    first_name='Kevin',\n    last_name='Systrom',\n    email='kevin@instagram.com',\n    company='Instagram',\n    phone_number='0102030405',\n    twitter='kevin',\n    position='CEO',\n    sync_status='success',\n    query='kevin',\n)\n```\n\nGet a specific lead by id:\n\n```python\nhunter.get_lead(42)\n```\n\nCreate a lead:\n\n```python\nhunter.create_lead(\n    'Quentin', 'Durantay',\n    email='quentin.durantay@unicorn.io',\n    position='CEO',\n    company='Unicorn Consulting',\n    company_size=10,\n    confidence_score=100,\n    website='unicornsaregreat.io',\n    country_code='FR',\n    postal_code=75000,\n    source='theinternet.com',\n    linkedin_url='www.linkedin.com/in/masteroftheuniverse',\n    phone_number='0102030405',\n    twitter='quentindty',\n    notes='Met at a conference',\n    leads_list_id=1,\n    leads_list_ids=[1, 2, 3],\n)\n```\n\nCreate or update a lead by email (upsert):\n\n```python\nhunter.upsert_lead('kevin@instagram.com', first_name='Kevin', last_name='Systrom')\n```\n\nUpdate a lead by id:\n\n```python\nhunter.update_lead(1, position='CEO in chief', notes='Updated notes')\n```\n\nDelete a lead by id:\n\n```python\nhunter.delete_lead(42)\n```\n\n---\n\n### Leads Lists\n\nGet all leads lists:\n\n```python\nhunter.get_leads_lists()\nhunter.get_leads_lists(offset=3, limit=2)\n```\n\nGet a specific leads list by id:\n\n```python\nhunter.get_leads_list(42)\n```\n\nCreate a leads list:\n\n```python\nhunter.create_leads_list('Ultra hot prospects')\n```\n\nUpdate a leads list:\n\n```python\nhunter.update_leads_list(42, 'Ultra mega hot prospects')\n```\n\nDelete a leads list:\n\n```python\nhunter.delete_leads_list(42)\n```\n\n---\n\n### Custom Attributes\n\nManage custom attributes for your leads:\n\n```python\n# List all custom attributes\nhunter.get_leads_custom_attributes()\n\n# Get a specific custom attribute\nhunter.get_leads_custom_attribute(1)\n\n# Create a new custom attribute\nhunter.create_leads_custom_attribute('Priority Level')\n\n# Update a custom attribute\nhunter.update_leads_custom_attribute(1, 'Deal Priority')\n\n# Delete a custom attribute\nhunter.delete_leads_custom_attribute(1)\n```\n\n---\n\n### Campaigns (Email Sequences)\n\nManage your email sequences:\n\n```python\n# List all campaigns\nhunter.get_campaigns()\nhunter.get_campaigns(started=True, limit=10)\n\n# Get recipients of a campaign\nhunter.get_campaign_recipients(42)\n\n# Add recipients to a campaign\nhunter.add_campaign_recipients(42, emails=['kevin@instagram.com', 'jack@twitter.com'])\nhunter.add_campaign_recipients(42, lead_ids=[1, 2, 3])\n\n# Cancel scheduled emails to recipients\nhunter.cancel_campaign_recipients(42, emails=['kevin@instagram.com'])\n\n# Start a campaign\nhunter.start_campaign(42)\n```\n\n---\n\n### Logos\n\nGet a company logo by domain (returns bytes):\n\n```python\nlogo_bytes = hunter.logo('stripe.com')\n```\n\nAsync:\n\n```python\nfrom pyhunter import AsyncPyHunter\n\nasync with AsyncPyHunter('my_hunter_api_key') as async_hunter:\n    logo_bytes = await async_hunter.logo('stripe.com')\n```\n\n---\n\n### Information\n\nIf 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).\n\n### Contribute\n\nIt'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 ;)\n\n### Have a nice day scraping B2B emails with PyHunter!\n"
  },
  {
    "path": "_config.yml",
    "content": "theme: jekyll-theme-minimal"
  },
  {
    "path": "pyhunter/__init__.py",
    "content": "from .async_pyhunter import AsyncPyHunter\nfrom .pyhunter import PyHunter\n\n__all__ = [\"PyHunter\", \"AsyncPyHunter\"]\n"
  },
  {
    "path": "pyhunter/_core.py",
    "content": "from .exceptions import HunterApiError\n\n\ndef parse_data_payload(response, endpoint, method):\n    try:\n        return response.json()[\"data\"]\n    except (KeyError, ValueError) as exc:\n        try:\n            payload_data = response.json()\n        except ValueError:\n            payload_data = {\"body\": response.text}\n        raise HunterApiError(\n            message=\"Hunter API response format is invalid\",\n            status_code=response.status_code,\n            payload=payload_data,\n            endpoint=endpoint,\n            method=method,\n        ) from exc\n"
  },
  {
    "path": "pyhunter/async_pyhunter.py",
    "content": "import asyncio\n\nimport httpx\n\nfrom ._core import parse_data_payload\nfrom .exceptions import (\n    HunterApiError,\n    HunterTransportError,\n    MissingCompanyError,\n    MissingNameError,\n)\n\n\nclass AsyncPyHunter:\n    def __init__(self, api_key, timeout=10, max_retries=2, retry_backoff=0.5,\n                 client=None):\n        self.api_key = api_key\n        self.base_endpoint = 'https://api.hunter.io/v2/{}'\n        self.timeout = timeout\n        self.max_retries = max_retries\n        self.retry_backoff = retry_backoff\n        self.client = client or httpx.AsyncClient(timeout=timeout)\n        self._owns_client = client is None\n\n    @property\n    def base_params(self):\n        return {'api_key': self.api_key}\n\n    async def __aenter__(self):\n        return self\n\n    async def __aexit__(self, exc_type, exc, tb):\n        await self.aclose()\n\n    async def aclose(self):\n        if self._owns_client:\n            await self.client.aclose()\n\n    async def _query_hunter(self, endpoint, params, request_type='get',\n                            payload=None, headers=None, raw=False):\n        request_kwargs = dict(params=params, json=payload, headers=headers)\n        attempt = 0\n        while attempt <= self.max_retries:\n            try:\n                res = await self.client.request(\n                    request_type.upper(),\n                    endpoint,\n                    **request_kwargs\n                )\n                res.raise_for_status()\n                break\n            except httpx.HTTPStatusError as exc:\n                status_code = exc.response.status_code if exc.response else None\n                should_retry = status_code in (429, 500, 502, 503, 504)\n                if should_retry and attempt < self.max_retries:\n                    await asyncio.sleep(self.retry_backoff * (2 ** attempt))\n                    attempt += 1\n                    continue\n                payload_data = None\n                if exc.response is not None:\n                    try:\n                        payload_data = exc.response.json()\n                    except ValueError:\n                        payload_data = {'body': exc.response.text}\n                raise HunterApiError(\n                    message='Hunter API returned an error response',\n                    status_code=status_code,\n                    error=(\n                        payload_data.get('errors')\n                        if isinstance(payload_data, dict)\n                        else None\n                    ),\n                    payload=payload_data,\n                    endpoint=endpoint,\n                    method=request_type.upper(),\n                ) from exc\n            except httpx.HTTPError as exc:\n                if attempt < self.max_retries:\n                    await asyncio.sleep(self.retry_backoff * (2 ** attempt))\n                    attempt += 1\n                    continue\n                raise HunterTransportError(\n                    message='Failed to reach Hunter API',\n                    endpoint=endpoint,\n                    method=request_type.upper(),\n                ) from exc\n\n        if raw:\n            return res\n\n        return parse_data_payload(res, endpoint, request_type.upper())\n\n    async def domain_search(self, domain=None, company=None, limit=None,\n                            offset=None, seniority=None, department=None,\n                            emails_type=None, required_field=None,\n                            verification_status=None, raw=False):\n        if domain:\n            params = {'domain': domain, 'api_key': self.api_key}\n        elif company:\n            params = {'company': company, 'api_key': self.api_key}\n        else:\n            raise MissingCompanyError(\n                'You must supply at least a domain name or a company name'\n            )\n        if limit is not None:\n            params['limit'] = limit\n        if offset is not None:\n            params['offset'] = offset\n        if seniority:\n            params['seniority'] = seniority\n        if department:\n            params['department'] = department\n        if emails_type:\n            params['type'] = emails_type\n        if required_field:\n            params['required_field'] = required_field\n        if verification_status:\n            params['verification_status'] = verification_status\n        endpoint = self.base_endpoint.format('domain-search')\n        return await self._query_hunter(endpoint, params, raw=raw)\n\n    async def email_finder(self, domain=None, company=None, first_name=None,\n                           last_name=None, full_name=None,\n                           linkedin_handle=None, max_duration=None, raw=False):\n        params = self.base_params\n        if not domain and not company and not linkedin_handle:\n            raise MissingCompanyError(\n                'You must supply at least a domain name, a company name, or a LinkedIn handle'\n            )\n        if domain:\n            params['domain'] = domain\n        elif company:\n            params['company'] = company\n        if linkedin_handle:\n            params['linkedin_handle'] = linkedin_handle\n        if not linkedin_handle and not (first_name and last_name) and not full_name:\n            raise MissingNameError(\n                'You must supply a first name AND a last name OR a full name'\n            )\n        if first_name and last_name:\n            params['first_name'] = first_name\n            params['last_name'] = last_name\n        elif full_name:\n            params['full_name'] = full_name\n        if max_duration:\n            params['max_duration'] = max_duration\n        endpoint = self.base_endpoint.format('email-finder')\n        res = await self._query_hunter(endpoint, params, raw=raw)\n        if raw:\n            return res\n        return res['email'], res['score']\n\n    async def email_verifier(self, email, raw=False):\n        params = {'email': email, 'api_key': self.api_key}\n        endpoint = self.base_endpoint.format('email-verifier')\n        return await self._query_hunter(endpoint, params, raw=raw)\n\n    async def email_count(self, domain=None, company=None, raw=False):\n        params = self.base_params\n        if not domain and not company:\n            raise MissingCompanyError(\n                'You must supply at least a domain name or a company name'\n            )\n        if domain:\n            params['domain'] = domain\n        elif company:\n            params['company'] = company\n        endpoint = self.base_endpoint.format('email-count')\n        return await self._query_hunter(endpoint, params, raw=raw)\n\n    async def account_information(self, raw=False):\n        params = self.base_params\n        endpoint = self.base_endpoint.format('account')\n        res = await self._query_hunter(endpoint, params, raw=raw)\n        if raw:\n            return res\n        res['calls']['left'] = res['calls']['available'] - res['calls']['used']\n        return res\n\n    async def logo(self, domain, raw=False):\n        \"\"\"\n        Returns company logo bytes for a given domain.\n\n        :param domain: The company's domain name. Must be defined.\n\n        :param raw: Gives back the entire response instead of just image bytes.\n\n        :return: Binary logo content (bytes) or a raw response object.\n        \"\"\"\n        endpoint = 'https://logos.hunter.io/{}'.format(domain)\n        try:\n            res = await self.client.get(endpoint)\n            res.raise_for_status()\n        except httpx.HTTPStatusError as exc:\n            status_code = exc.response.status_code if exc.response else None\n            raise HunterApiError(\n                message='Hunter logos endpoint returned an error response',\n                status_code=status_code,\n                payload={'body': exc.response.text} if exc.response is not None else None,\n                endpoint=endpoint,\n                method='GET',\n            ) from exc\n        except httpx.HTTPError as exc:\n            raise HunterTransportError(\n                message='Failed to reach Hunter logos endpoint',\n                endpoint=endpoint,\n                method='GET',\n            ) from exc\n\n        if raw:\n            return res\n        return res.content\n"
  },
  {
    "path": "pyhunter/exceptions.py",
    "content": "class PyhunterError(Exception):\n    \"\"\"\n    Generic exception class for the library\n    \"\"\"\n    pass\n\n\nclass MissingCompanyError(PyhunterError):\n    pass\n\n\nclass MissingNameError(PyhunterError):\n    pass\n\n\nclass HunterApiError(PyhunterError):\n    \"\"\"\n    Represents something went wrong in the call to the Hunter API\n    \"\"\"\n    def __init__(self, message='Hunter API request failed', status_code=None,\n                 error=None, payload=None, endpoint=None, method=None):\n        super().__init__(message)\n        self.status_code = status_code\n        self.error = error\n        self.payload = payload\n        self.endpoint = endpoint\n        self.method = method\n\n\nclass HunterTransportError(PyhunterError):\n    \"\"\"\n    Represents transport-level errors while calling Hunter API\n    \"\"\"\n    def __init__(self, message='Hunter API transport failed', endpoint=None,\n                 method=None):\n        super().__init__(message)\n        self.endpoint = endpoint\n        self.method = method\n"
  },
  {
    "path": "pyhunter/pyhunter.py",
    "content": "from time import sleep\n\nimport requests\n\nfrom ._core import parse_data_payload\nfrom .exceptions import (\n    HunterApiError,\n    HunterTransportError,\n    MissingCompanyError,\n    MissingNameError,\n)\n\n\nclass PyHunter:\n    def __init__(self, api_key, timeout=10, max_retries=2, retry_backoff=0.5,\n                 session=None):\n        self.api_key = api_key\n        self.base_endpoint = 'https://api.hunter.io/v2/{}'\n        self.timeout = timeout\n        self.max_retries = max_retries\n        self.retry_backoff = retry_backoff\n        self.session = session or requests.Session()\n\n    @property\n    def base_params(self):\n        return {'api_key': self.api_key}\n\n    def _query_hunter(self, endpoint, params, request_type='get',\n                      payload=None, headers=None, raw=False):\n        request_kwargs = dict(\n            params=params,\n            json=payload,\n            headers=headers,\n            timeout=self.timeout,\n        )\n        attempt = 0\n        last_error = None\n        while attempt <= self.max_retries:\n            try:\n                res = getattr(self.session, request_type)(endpoint, **request_kwargs)\n                res.raise_for_status()\n                break\n            except requests.exceptions.HTTPError as exc:\n                status_code = exc.response.status_code if exc.response else None\n                should_retry = status_code in (429, 500, 502, 503, 504)\n                if should_retry and attempt < self.max_retries:\n                    sleep(self.retry_backoff * (2 ** attempt))\n                    attempt += 1\n                    continue\n                message = 'Hunter API returned an error response'\n                payload_data = None\n                if exc.response is not None:\n                    try:\n                        payload_data = exc.response.json()\n                    except ValueError:\n                        payload_data = {'body': exc.response.text}\n                raise HunterApiError(\n                    message=message,\n                    status_code=status_code,\n                    error=(\n                        payload_data.get('errors')\n                        if isinstance(payload_data, dict)\n                        else None\n                    ),\n                    payload=payload_data,\n                    endpoint=endpoint,\n                    method=request_type.upper(),\n                ) from exc\n            except requests.exceptions.RequestException as exc:\n                last_error = exc\n                if attempt < self.max_retries:\n                    sleep(self.retry_backoff * (2 ** attempt))\n                    attempt += 1\n                    continue\n                raise HunterTransportError(\n                    message='Failed to reach Hunter API',\n                    endpoint=endpoint,\n                    method=request_type.upper(),\n                ) from exc\n        else:\n            raise HunterTransportError(\n                message='Failed to reach Hunter API',\n                endpoint=endpoint,\n                method=request_type.upper(),\n            ) from last_error\n\n        if raw:\n            return res\n\n        return parse_data_payload(res, endpoint, request_type.upper())\n\n    def domain_search(self, domain=None, company=None, limit=None, offset=None,\n                      seniority=None, department=None, emails_type=None,\n                      required_field=None, verification_status=None, raw=False):\n        \"\"\"\n        Return all the email addresses found for a given domain.\n\n        :param domain: The domain on which to search for emails. Must be\n        defined if company is not.\n\n        :param company: The name of the company on which to search for emails.\n        Must be defined if domain is not.\n\n        :param limit: The maximum number of emails to give back. Default is 10.\n\n        :param offset: The number of emails to skip. Default is 0.\n\n        :param seniority: The seniority level of the owners of emails to give back. Can be 'junior', 'senior',\n        'executive' or a combination of them delimited by a comma.\n\n        :param department: The department where the owners of the emails to give back work. Can be 'executive', 'it',\n        'finance', 'management', 'sales', 'legal', 'support', 'hr', 'marketing', 'communication' or a combination of\n        them delimited by a comma.\n\n        :param emails_type: The type of emails to give back. Can be one of\n        'personal' or 'generic'.\n\n        :param required_field: Only return emails with this field present. Can be\n        'full_name', 'position', or 'phone_number'.\n\n        :param verification_status: Only return emails with this verification status.\n        Can be 'valid', 'accept_all', or 'unknown'.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict, with email addresses\n        found.\n        \"\"\"\n        if domain:\n            params = {'domain': domain, 'api_key': self.api_key}\n        elif company:\n            params = {'company': company, 'api_key': self.api_key}\n        else:\n            raise MissingCompanyError(\n                'You must supply at least a domain name or a company name'\n            )\n\n        if limit is not None:\n            params['limit'] = limit\n\n        if offset is not None:\n            params['offset'] = offset\n\n        if seniority:\n            params['seniority'] = seniority\n\n        if department:\n            params['department'] = department\n\n        if emails_type:\n            params['type'] = emails_type\n\n        if required_field:\n            params['required_field'] = required_field\n\n        if verification_status:\n            params['verification_status'] = verification_status\n\n        endpoint = self.base_endpoint.format('domain-search')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def email_finder(self, domain=None, company=None, first_name=None,\n                     last_name=None, full_name=None, linkedin_handle=None,\n                     max_duration=None, raw=False):\n        \"\"\"\n        Find the email address of a person given its name and company's domain.\n\n        :param domain: The domain of the company where the person works. Must\n        be defined if company and linkedin_handle are not.\n\n        :param company: The name of the company where the person works. Must\n        be defined if domain and linkedin_handle are not.\n\n        :param first_name: The first name of the person. Must be defined if\n        full_name is not.\n\n        :param last_name: The last name of the person. Must be defined if\n        full_name is not.\n\n        :param full_name: The full name of the person. Must be defined if\n        first_name AND last_name are not.\n\n        :param linkedin_handle: The LinkedIn handle of the person (e.g.\n        'johnsmith'). Can be used instead of domain/company + name.\n\n        :param max_duration: Maximum duration of the request in seconds (3-20,\n        default 10).\n\n        :param raw: Gives back the entire response instead of just email and score.\n\n        :return: email and score as a tuple.\n        \"\"\"\n        params = self.base_params\n\n        if not domain and not company and not linkedin_handle:\n            raise MissingCompanyError(\n                'You must supply at least a domain name, a company name, or a LinkedIn handle'\n            )\n\n        if domain:\n            params['domain'] = domain\n        elif company:\n            params['company'] = company\n\n        if linkedin_handle:\n            params['linkedin_handle'] = linkedin_handle\n\n        if not linkedin_handle:\n            if not(first_name and last_name) and not full_name:\n                raise MissingNameError(\n                    'You must supply a first name AND a last name OR a full name'\n                )\n\n        if first_name and last_name:\n            params['first_name'] = first_name\n            params['last_name'] = last_name\n        elif full_name:\n            params['full_name'] = full_name\n\n        if max_duration:\n            params['max_duration'] = max_duration\n\n        endpoint = self.base_endpoint.format('email-finder')\n\n        res = self._query_hunter(endpoint, params, raw=raw)\n        if raw:\n            return res\n\n        email = res['email']\n        score = res['score']\n\n        return email, score\n\n    def email_verifier(self, email, raw=False):\n        \"\"\"\n        Verify the deliverability of a given email address.\n\n        :param email: The email address to check.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict.\n        \"\"\"\n        params = {'email': email, 'api_key': self.api_key}\n\n        endpoint = self.base_endpoint.format('email-verifier')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def email_count(self, domain=None, company=None, raw=False):\n        \"\"\"\n        Give back the number of email addresses Hunter has for this domain/company.\n\n        :param domain: The domain of the company where the person works. Must\n        be defined if company is not. If both 'domain' and 'company' are given,\n        the 'domain' will be used.\n\n        :param company: The name of the company where the person works. Must\n        be defined if domain is not.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict.\n        \"\"\"\n        params = self.base_params\n\n        if not domain and not company:\n            raise MissingCompanyError(\n                'You must supply at least a domain name or a company name'\n            )\n\n        if domain:\n            params['domain'] = domain\n        elif company:\n            params['company'] = company\n\n        endpoint = self.base_endpoint.format('email-count')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def account_information(self, raw=False):\n        \"\"\"\n        Gives the information about the account associated with the api_key.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format('account')\n\n        res = self._query_hunter(endpoint, params, raw=raw)\n        if raw:\n            return res\n\n        res['calls']['left'] = res['calls']['available'] - res['calls']['used']\n\n        return res\n\n    # ---------------------------------------------------------------------------\n    # Enrichment\n    # ---------------------------------------------------------------------------\n\n    def email_enrichment(self, email=None, linkedin_handle=None,\n                         clearbit_format=None, raw=False):\n        \"\"\"\n        Returns all information about a person given their email or LinkedIn handle.\n\n        :param email: The person's email address. Must be defined if\n        linkedin_handle is not.\n\n        :param linkedin_handle: The person's LinkedIn profile handle. Must be\n        defined if email is not.\n\n        :param clearbit_format: If True, returns the response in Clearbit format.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict.\n        \"\"\"\n        params = {'api_key': self.api_key}\n\n        if email:\n            params['email'] = email\n        elif linkedin_handle:\n            params['linkedin_handle'] = linkedin_handle\n        else:\n            raise MissingCompanyError(\n                'You must supply at least an email or a LinkedIn handle'\n            )\n\n        if clearbit_format is not None:\n            params['clearbit_format'] = clearbit_format\n\n        endpoint = self.base_endpoint.format('people/find')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def company_enrichment(self, domain, clearbit_format=None, raw=False):\n        \"\"\"\n        Returns all information about a company given its domain.\n\n        :param domain: The company's domain name. Must be defined.\n\n        :param clearbit_format: If True, returns the response in Clearbit format.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict.\n        \"\"\"\n        params = {'domain': domain, 'api_key': self.api_key}\n\n        if clearbit_format is not None:\n            params['clearbit_format'] = clearbit_format\n\n        endpoint = self.base_endpoint.format('companies/find')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def combined_enrichment(self, email, clearbit_format=None, raw=False):\n        \"\"\"\n        Returns information about both a person and their company given the\n        person's email address.\n\n        :param email: The person's email address. Must be defined.\n\n        :param clearbit_format: If True, returns the response in Clearbit format.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict with 'person' and\n        'company' keys.\n        \"\"\"\n        params = {'email': email, 'api_key': self.api_key}\n\n        if clearbit_format is not None:\n            params['clearbit_format'] = clearbit_format\n\n        endpoint = self.base_endpoint.format('combined/find')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    # ---------------------------------------------------------------------------\n    # Discover\n    # ---------------------------------------------------------------------------\n\n    def discover(self, query=None, organization=None, similar_to=None,\n                 headquarters_location=None, industry=None, headcount=None,\n                 company_type=None, year_founded=None, keywords=None,\n                 technology=None, funding=None, limit=None, offset=None,\n                 raw=False):\n        \"\"\"\n        Returns companies matching a set of criteria.\n\n        :param query: Natural language search query (e.g. 'Tech companies in Europe').\n\n        :param organization: Filter by domain and/or company name. Dict with\n        optional 'domain' (list) and 'name' (list) keys.\n\n        :param similar_to: Find companies similar to this one (Premium). Dict\n        with 'domain' or 'name' key.\n\n        :param headquarters_location: Filter by location. Dict with 'include'\n        and/or 'exclude' lists, each containing dicts with optional 'continent',\n        'country', 'state', 'city' keys.\n\n        :param industry: Filter by industry. Dict with 'include' and/or\n        'exclude' lists.\n\n        :param headcount: Filter by company size. List of ranges such as\n        '1-10', '11-50', '51-200', etc.\n\n        :param company_type: Filter by company type. Dict with 'include'\n        and/or 'exclude' lists.\n\n        :param year_founded: Filter by founding year (Premium). Dict with\n        optional 'from', 'to', 'include', 'exclude' keys.\n\n        :param keywords: Filter by keywords (Premium). Dict with 'include'\n        and/or 'exclude' lists and optional 'match' ('any'/'all').\n\n        :param technology: Filter by technologies used (Premium). Dict with\n        'include' and/or 'exclude' lists and optional 'match' ('any'/'all').\n\n        :param funding: Filter by funding (Premium). Dict with optional\n        'series', 'amount', and 'date' keys.\n\n        :param limit: Maximum number of companies to return (default 100).\n\n        :param offset: Number of companies to skip (default 0).\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Full payload of the query as a dict with a list of companies.\n        \"\"\"\n        params = {'api_key': self.api_key}\n\n        payload = {}\n        if query is not None:\n            payload['query'] = query\n        if organization is not None:\n            payload['organization'] = organization\n        if similar_to is not None:\n            payload['similar_to'] = similar_to\n        if headquarters_location is not None:\n            payload['headquarters_location'] = headquarters_location\n        if industry is not None:\n            payload['industry'] = industry\n        if headcount is not None:\n            payload['headcount'] = headcount\n        if company_type is not None:\n            payload['company_type'] = company_type\n        if year_founded is not None:\n            payload['year_founded'] = year_founded\n        if keywords is not None:\n            payload['keywords'] = keywords\n        if technology is not None:\n            payload['technology'] = technology\n        if funding is not None:\n            payload['funding'] = funding\n        if limit is not None:\n            payload['limit'] = limit\n        if offset is not None:\n            payload['offset'] = offset\n\n        endpoint = self.base_endpoint.format('discover')\n\n        return self._query_hunter(endpoint, params, 'post', payload, raw=raw)\n\n    # ---------------------------------------------------------------------------\n    # Leads\n    # ---------------------------------------------------------------------------\n\n    def get_leads(self, offset=None, limit=None, lead_list_id=None,\n                  first_name=None, last_name=None, email=None, company=None,\n                  phone_number=None, twitter=None, position=None,\n                  industry=None, website=None, country_code=None,\n                  company_size=None, source=None, linkedin_url=None,\n                  sync_status=None, sending_status=None,\n                  verification_status=None, last_activity_at=None,\n                  last_contacted_at=None, custom_attributes=None, query=None):\n        \"\"\"\n        Gives back all the leads saved in your account.\n\n        :param offset: Number of leads to skip.\n\n        :param limit: Maximum number of leads to return (1-1000, default 20).\n\n        :param lead_list_id: Id of a lead list to query leads on.\n\n        :param first_name: First name to filter on.\n\n        :param last_name: Last name to filter on.\n\n        :param email: Email to filter on.\n\n        :param company: Company to filter on.\n\n        :param phone_number: Phone number to filter on.\n\n        :param twitter: Twitter account to filter on.\n\n        :param position: Job position to filter on.\n\n        :param industry: Industry to filter on.\n\n        :param website: Website to filter on.\n\n        :param country_code: Country code to filter on.\n\n        :param company_size: Company size to filter on.\n\n        :param source: Lead source to filter on.\n\n        :param linkedin_url: LinkedIn URL to filter on.\n\n        :param sync_status: Filter by sync status ('pending', 'error', 'success').\n\n        :param sending_status: Filter by sending status. Can be a list of values\n        ('clicked', 'opened', 'sent', 'pending', 'error', 'bounced',\n        'unsubscribed', 'replied').\n\n        :param verification_status: Filter by verification status. Can be a list\n        of values ('accept_all', 'disposable', 'invalid', 'unknown', 'valid',\n        'webmail', 'pending').\n\n        :param last_activity_at: Filter by last activity date.\n\n        :param last_contacted_at: Filter by last contacted date.\n\n        :param custom_attributes: Dict of custom attribute slugs and values to\n        filter on.\n\n        :param query: Search query matching first_name, last_name, or email.\n\n        :return: All leads found as a dict.\n        \"\"\"\n        args = locals()\n        args_params = dict((key, value) for key, value in args.items() if value\n                           is not None)\n        args_params.pop('self')\n\n        params = self.base_params\n        params.update(args_params)\n\n        endpoint = self.base_endpoint.format('leads')\n\n        return self._query_hunter(endpoint, params)\n\n    def get_lead(self, lead_id):\n        \"\"\"\n        Get a specific lead saved on your account.\n\n        :param lead_id: Id of the lead to search. Must be defined.\n\n        :return: Lead found as a dict.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format('leads/' + str(lead_id))\n\n        return self._query_hunter(endpoint, params)\n\n    def create_lead(self, first_name, last_name, email=None, position=None,\n                    company=None, company_industry=None, company_size=None,\n                    confidence_score=None, website=None, country_code=None,\n                    postal_code=None, source=None, linkedin_url=None,\n                    phone_number=None, twitter=None, notes=None,\n                    leads_list_id=None, leads_list_ids=None):\n        \"\"\"\n        Create a lead on your account.\n\n        :param first_name: The first name of the lead to create. Must be\n        defined.\n\n        :param last_name: The last name of the lead to create. Must be defined.\n\n        :param email: The email of the lead to create.\n\n        :param position: The professional position of the lead to create.\n\n        :param company: The company of the lead to create.\n\n        :param company_industry: The type of industry of the company where the\n        lead works.\n\n        :param company_size: The size of the company where the lead works.\n\n        :param confidence_score: The confidence score of the lead's email.\n\n        :param website: The website of the lead's company.\n\n        :param country_code: The country code of the lead's company.\n\n        :param postal_code: The postal code of the lead's company.\n\n        :param source: The source of the lead's email.\n\n        :param linkedin_url: The URL of the lead's LinkedIn profile.\n\n        :param phone_number: The phone number of the lead to create.\n\n        :param twitter: The lead's Twitter account.\n\n        :param notes: Notes about the lead.\n\n        :param leads_list_id: The id of the leads list where to save the new\n        lead.\n\n        :param leads_list_ids: A list of leads list ids to assign the lead to\n        multiple lists at once.\n\n        :return: The newly created lead as a dict.\n        \"\"\"\n        args = locals()\n        payload = dict((key, value) for key, value in args.items() if value\n                       is not None)\n        payload.pop('self')\n\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format('leads')\n\n        return self._query_hunter(endpoint, params, 'post', payload)\n\n    def upsert_lead(self, email, first_name=None, last_name=None,\n                    position=None, company=None, company_industry=None,\n                    company_size=None, confidence_score=None, website=None,\n                    country_code=None, postal_code=None, source=None,\n                    linkedin_url=None, phone_number=None, twitter=None,\n                    notes=None, leads_list_id=None, leads_list_ids=None):\n        \"\"\"\n        Create or update a lead based on their email address (upsert).\n        If a lead with the given email already exists, it is updated; otherwise\n        a new lead is created.\n\n        :param email: The email of the lead. Must be defined.\n\n        :param first_name: The first name of the lead.\n\n        :param last_name: The last name of the lead.\n\n        :param position: The professional position of the lead.\n\n        :param company: The company of the lead.\n\n        :param company_industry: The type of industry of the company where the\n        lead works.\n\n        :param company_size: The size of the company where the lead works.\n\n        :param confidence_score: The confidence score of the lead's email.\n\n        :param website: The website of the lead's company.\n\n        :param country_code: The country code of the lead's company.\n\n        :param postal_code: The postal code of the lead's company.\n\n        :param source: The source of the lead's email.\n\n        :param linkedin_url: The URL of the lead's LinkedIn profile.\n\n        :param phone_number: The phone number of the lead.\n\n        :param twitter: The lead's Twitter account.\n\n        :param notes: Notes about the lead.\n\n        :param leads_list_id: The id of the leads list to save the lead to.\n\n        :param leads_list_ids: A list of leads list ids to assign the lead to\n        multiple lists at once.\n\n        :return: The created or updated lead as a dict.\n        \"\"\"\n        args = locals()\n        payload = dict((key, value) for key, value in args.items() if value\n                       is not None)\n        payload.pop('self')\n\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format('leads')\n\n        return self._query_hunter(endpoint, params, 'put', payload)\n\n    def update_lead(self, lead_id, first_name=None, last_name=None, email=None,\n                    position=None, company=None, company_industry=None,\n                    company_size=None, confidence_score=None, website=None,\n                    country_code=None, postal_code=None, source=None,\n                    linkedin_url=None, phone_number=None, twitter=None,\n                    notes=None, leads_list_id=None, leads_list_ids=None):\n        \"\"\"\n        Update a lead on your account.\n\n        :param lead_id: The id of the lead to update. Must be defined.\n\n        :param first_name: The first name of the lead to update.\n\n        :param last_name: The last name of the lead to update.\n\n        :param email: The email of the lead to update.\n\n        :param position: The professional position of the lead to update.\n\n        :param company: The company of the lead to update.\n\n        :param company_industry: The type of industry of the company where the\n        lead works.\n\n        :param company_size: The size of the company where the lead works.\n\n        :param confidence_score: The confidence score of the lead's email.\n\n        :param website: The website of the lead's company.\n\n        :param country_code: The country code of the lead's company.\n\n        :param postal_code: The postal code of the lead's company.\n\n        :param source: The source of the lead's email.\n\n        :param linkedin_url: The URL of the lead's LinkedIn profile.\n\n        :param phone_number: The phone number of the lead to update.\n\n        :param twitter: The lead's Twitter account.\n\n        :param notes: Notes about the lead.\n\n        :param leads_list_id: The id of the leads list where to save the\n        lead.\n\n        :param leads_list_ids: A list of leads list ids to assign the lead to\n        multiple lists at once.\n\n        :return: The newly updated lead as a dict.\n        \"\"\"\n        args = locals()\n        payload = dict((key, value) for key, value in args.items() if value\n                       is not None)\n        payload.pop('self')\n        payload.pop('lead_id')\n\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format('leads/' + str(lead_id))\n\n        return self._query_hunter(endpoint, params, 'put', payload)\n\n    def delete_lead(self, lead_id):\n        \"\"\"\n        Delete a specific lead saved on your account.\n\n        :param lead_id: Id of the lead to delete. Must be defined.\n\n        :return: 204 Response.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format('leads/' + str(lead_id))\n\n        return self._query_hunter(endpoint, params, 'delete')\n\n    # ---------------------------------------------------------------------------\n    # Leads Lists\n    # ---------------------------------------------------------------------------\n\n    def get_leads_lists(self, offset=None, limit=None):\n        \"\"\"\n        Gives back all the leads lists saved on your account.\n\n        :param offset: Number of lists to skip.\n\n        :param limit: Maximum number of lists to return.\n\n        :return: Leads lists found as a dict.\n        \"\"\"\n        params = self.base_params\n\n        if offset:\n            params['offset'] = offset\n        if limit:\n            params['limit'] = limit\n\n        endpoint = self.base_endpoint.format('leads_lists')\n\n        return self._query_hunter(endpoint, params)\n\n    def get_leads_list(self, leads_list_id):\n        \"\"\"\n        Gives back a specific leads list saved on your account.\n\n        :param leads_list_id: The id of the list to return.\n\n        :return: Leads list found as a dict.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format(\n            'leads_lists/' +\n            str(leads_list_id)\n        )\n\n        return self._query_hunter(endpoint, params)\n\n    def create_leads_list(self, name, team_id=None):\n        \"\"\"\n        Create a leads list.\n\n        :param name: Name of the list to create. Must be defined.\n\n        :param team_id: The id of the list to share this list with.\n\n        :return: The created leads list as a dict.\n        \"\"\"\n        params = self.base_params\n\n        payload = {'name': name}\n        if team_id:\n            payload['team_id'] = team_id\n\n        endpoint = self.base_endpoint.format('leads_lists')\n\n        return self._query_hunter(endpoint, params, 'post', payload)\n\n    def update_leads_list(self, leads_list_id, name, team_id=None):\n        \"\"\"\n        Update a leads list.\n\n        :param leads_list_id: The id of the list to update.\n\n        :param name: Name of the list to update. Must be defined.\n\n        :param team_id: The id of the list to share this list with.\n\n        :return: 204 Response.\n        \"\"\"\n        params = self.base_params\n\n        payload = {'name': name}\n        if team_id:\n            payload['team_id'] = team_id\n\n        endpoint = self.base_endpoint.format(\n            'leads_lists/' + str(leads_list_id))\n\n        return self._query_hunter(endpoint, params, 'put', payload)\n\n    def delete_leads_list(self, leads_list_id):\n        \"\"\"\n        Delete a leads list.\n\n        :param leads_list_id: The id of the list to delete.\n\n        :return: 204 Response.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format(\n            'leads_lists/' +\n            str(leads_list_id)\n        )\n\n        return self._query_hunter(endpoint, params, 'delete')\n\n    # ---------------------------------------------------------------------------\n    # Custom Attributes\n    # ---------------------------------------------------------------------------\n\n    def get_leads_custom_attributes(self, raw=False):\n        \"\"\"\n        Returns all custom attributes defined on your account.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: All custom attributes as a dict.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format('leads_custom_attributes')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def get_leads_custom_attribute(self, attribute_id, raw=False):\n        \"\"\"\n        Returns a specific custom attribute.\n\n        :param attribute_id: The id of the custom attribute. Must be defined.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: The custom attribute as a dict.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format(\n            'leads_custom_attributes/' + str(attribute_id)\n        )\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def create_leads_custom_attribute(self, label, raw=False):\n        \"\"\"\n        Creates a new custom attribute.\n\n        :param label: The name of the custom attribute. Must be unique and\n        defined.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: The newly created custom attribute as a dict.\n        \"\"\"\n        params = self.base_params\n        payload = {'label': label}\n\n        endpoint = self.base_endpoint.format('leads_custom_attributes')\n\n        return self._query_hunter(endpoint, params, 'post', payload, raw=raw)\n\n    def update_leads_custom_attribute(self, attribute_id, label, raw=False):\n        \"\"\"\n        Updates a custom attribute.\n\n        :param attribute_id: The id of the custom attribute to update. Must be\n        defined.\n\n        :param label: The new name for the custom attribute. Must be defined.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: 204 Response.\n        \"\"\"\n        params = self.base_params\n        payload = {'label': label}\n\n        endpoint = self.base_endpoint.format(\n            'leads_custom_attributes/' + str(attribute_id)\n        )\n\n        return self._query_hunter(endpoint, params, 'put', payload, raw=raw)\n\n    def delete_leads_custom_attribute(self, attribute_id):\n        \"\"\"\n        Deletes a custom attribute.\n\n        :param attribute_id: The id of the custom attribute to delete. Must be\n        defined.\n\n        :return: 204 Response.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format(\n            'leads_custom_attributes/' + str(attribute_id)\n        )\n\n        return self._query_hunter(endpoint, params, 'delete')\n\n    # ---------------------------------------------------------------------------\n    # Campaigns (Email Sequences)\n    # ---------------------------------------------------------------------------\n\n    def get_campaigns(self, started=None, archived=None, limit=None,\n                      offset=None, raw=False):\n        \"\"\"\n        Returns all email sequences (campaigns) on your account.\n\n        :param started: If True, only return started sequences.\n\n        :param archived: If True, only return archived sequences.\n\n        :param limit: Maximum number of sequences to return (1-100, default 20).\n\n        :param offset: Number of sequences to skip.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: All campaigns as a dict.\n        \"\"\"\n        params = self.base_params\n\n        if started is not None:\n            params['started'] = started\n        if archived is not None:\n            params['archived'] = archived\n        if limit is not None:\n            params['limit'] = limit\n        if offset is not None:\n            params['offset'] = offset\n\n        endpoint = self.base_endpoint.format('campaigns')\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def get_campaign_recipients(self, campaign_id, limit=None, offset=None,\n                                raw=False):\n        \"\"\"\n        Returns all recipients of a specific email sequence.\n\n        :param campaign_id: The id of the campaign. Must be defined.\n\n        :param limit: Maximum number of recipients to return (1-100, default 20).\n\n        :param offset: Number of recipients to skip.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: All recipients as a dict.\n        \"\"\"\n        params = self.base_params\n\n        if limit is not None:\n            params['limit'] = limit\n        if offset is not None:\n            params['offset'] = offset\n\n        endpoint = self.base_endpoint.format(\n            'campaigns/' + str(campaign_id) + '/recipients'\n        )\n\n        return self._query_hunter(endpoint, params, raw=raw)\n\n    def add_campaign_recipients(self, campaign_id, emails=None,\n                                lead_ids=None, raw=False):\n        \"\"\"\n        Adds recipients to an email sequence.\n\n        :param campaign_id: The id of the campaign. Must be defined.\n\n        :param emails: A single email string or a list of up to 50 email\n        addresses to add.\n\n        :param lead_ids: A list of up to 50 lead ids to add.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Number of recipients added and any skipped recipients.\n        \"\"\"\n        params = self.base_params\n        payload = {}\n\n        if emails is not None:\n            payload['emails'] = emails\n        if lead_ids is not None:\n            payload['lead_ids'] = lead_ids\n\n        endpoint = self.base_endpoint.format(\n            'campaigns/' + str(campaign_id) + '/recipients'\n        )\n\n        return self._query_hunter(endpoint, params, 'post', payload, raw=raw)\n\n    def cancel_campaign_recipients(self, campaign_id, emails, raw=False):\n        \"\"\"\n        Cancels scheduled emails to recipients of a campaign.\n\n        :param campaign_id: The id of the campaign. Must be defined.\n\n        :param emails: A single email string or a list of up to 50 email\n        addresses whose scheduled emails should be cancelled.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Dict with cancelled recipients and message count.\n        \"\"\"\n        params = self.base_params\n        payload = {'emails': emails}\n\n        endpoint = self.base_endpoint.format(\n            'campaigns/' + str(campaign_id) + '/recipients'\n        )\n\n        return self._query_hunter(endpoint, params, 'delete', payload, raw=raw)\n\n    def start_campaign(self, campaign_id, raw=False):\n        \"\"\"\n        Starts a draft email sequence.\n\n        :param campaign_id: The id of the campaign to start. Must be defined.\n\n        :param raw: Gives back the entire response instead of just the 'data'.\n\n        :return: Dict with a confirmation message and recipient count.\n        \"\"\"\n        params = self.base_params\n\n        endpoint = self.base_endpoint.format(\n            'campaigns/' + str(campaign_id) + '/start'\n        )\n\n        return self._query_hunter(endpoint, params, 'post', raw=raw)\n\n    # ---------------------------------------------------------------------------\n    # Logos\n    # ---------------------------------------------------------------------------\n\n    def logo(self, domain, raw=False):\n        \"\"\"\n        Returns company logo bytes for a given domain.\n\n        :param domain: The company's domain name. Must be defined.\n\n        :param raw: Gives back the entire response instead of just image bytes.\n\n        :return: Binary logo content (bytes) or a raw response object.\n        \"\"\"\n        endpoint = 'https://logos.hunter.io/{}'.format(domain)\n        try:\n            res = self.session.get(endpoint, timeout=self.timeout)\n            res.raise_for_status()\n        except requests.exceptions.HTTPError as exc:\n            status_code = exc.response.status_code if exc.response else None\n            raise HunterApiError(\n                message='Hunter logos endpoint returned an error response',\n                status_code=status_code,\n                payload={'body': exc.response.text} if exc.response is not None else None,\n                endpoint=endpoint,\n                method='GET',\n            ) from exc\n        except requests.exceptions.RequestException as exc:\n            raise HunterTransportError(\n                message='Failed to reach Hunter logos endpoint',\n                endpoint=endpoint,\n                method='GET',\n            ) from exc\n\n        if raw:\n            return res\n        return res.content\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=69\", \"wheel\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"pyhunter\"\nversion = \"2.1.0\"\ndescription = \"An (unofficial) Python wrapper for the Hunter.io API\"\nreadme = \"README.md\"\nrequires-python = \">=3.8\"\nlicense = { text = \"MIT\" }\nauthors = [\n  { name = \"Quentin Durantay\", email = \"quentin.durantay@gmail.com\" }\n]\nkeywords = [\"hunter\", \"hunter.io\", \"lead generation\", \"lead enrichment\"]\nclassifiers = [\n  \"Development Status :: 4 - Beta\",\n  \"Natural Language :: English\",\n  \"License :: OSI Approved :: MIT License\",\n  \"Programming Language :: Python\",\n  \"Programming Language :: Python :: 3\",\n  \"Programming Language :: Python :: 3.8\",\n  \"Programming Language :: Python :: 3.9\",\n  \"Programming Language :: Python :: 3.10\",\n  \"Programming Language :: Python :: 3.11\",\n  \"Programming Language :: Python :: 3.12\",\n  \"Programming Language :: Python :: 3.13\",\n  \"Topic :: Utilities\",\n]\ndependencies = [\n  \"requests>=2.20.0\",\n  \"httpx>=0.27.0\",\n]\n\n[project.optional-dependencies]\ntest = [\n  \"pytest>=8.0.0\",\n  \"pytest-asyncio>=0.23.0\",\n  \"pytest-cov>=5.0.0\",\n]\ndev = [\n  \"mypy>=1.10.0\",\n  \"ruff>=0.5.0\",\n  \"build>=1.2.1\",\n  \"twine>=5.1.0\",\n  \"pytest>=8.0.0\",\n  \"pytest-asyncio>=0.23.0\",\n  \"pytest-cov>=5.0.0\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/VonStruddle/PyHunter\"\nRepository = \"https://github.com/VonStruddle/PyHunter\"\n\n[tool.setuptools]\npackages = [\"pyhunter\"]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\nasyncio_default_fixture_loop_scope = \"function\"\n\n[tool.ruff]\nline-length = 120\ntarget-version = \"py38\"\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\"]\n\n[tool.mypy]\npython_version = \"3.9\"\nignore_missing_imports = true\nwarn_unused_configs = true\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "################################################################################\n# This requirements file has been automatically generated from `Pipfile` with\n# `pipenv-to-requirements`\n#\n#\n# This has been done to maintain backward compatibility with tools and services\n# that do not support `Pipfile` yet.\n#\n# Do NOT edit it directly, use `pipenv install [-d]` to modify `Pipfile` and\n# `Pipfile.lock` and then regenerate `requirements*.txt`.\n################################################################################\n\npytest>=8.0.0\npytest-asyncio>=0.23.0\nruff>=0.5.0\nmypy>=1.10.0\npytest-cov>=5.0.0\n"
  },
  {
    "path": "requirements.txt",
    "content": "################################################################################\n# This requirements file has been automatically generated from `Pipfile` with\n# `pipenv-to-requirements`\n#\n#\n# This has been done to maintain backward compatibility with tools and services\n# that do not support `Pipfile` yet.\n#\n# Do NOT edit it directly, use `pipenv install [-d]` to modify `Pipfile` and\n# `Pipfile.lock` and then regenerate `requirements*.txt`.\n################################################################################\n\nrequests>=2.20.0\nhttpx>=0.27.0\n"
  },
  {
    "path": "setup.cfg",
    "content": "[metadata]\ndescription_file = README.md\n"
  },
  {
    "path": "setup.py",
    "content": "from setuptools import setup\n\nif __name__ == \"__main__\":\n    setup()\n"
  },
  {
    "path": "tests/test_async_client.py",
    "content": "from unittest.mock import AsyncMock\n\nimport httpx\nimport pytest\n\nfrom pyhunter import AsyncPyHunter\nfrom pyhunter.exceptions import HunterApiError, MissingCompanyError, MissingNameError\n\n\n@pytest.mark.asyncio\nasync def test_async_domain_search_requires_domain_or_company():\n    hunter = AsyncPyHunter(\"key\")\n    with pytest.raises(MissingCompanyError):\n        await hunter.domain_search()\n    await hunter.aclose()\n\n\n@pytest.mark.asyncio\nasync def test_async_email_finder_requires_name_when_no_linkedin():\n    hunter = AsyncPyHunter(\"key\")\n    with pytest.raises(MissingNameError):\n        await hunter.email_finder(domain=\"example.com\")\n    await hunter.aclose()\n\n\n@pytest.mark.asyncio\nasync def test_async_email_finder_returns_tuple():\n    hunter = AsyncPyHunter(\"key\")\n    hunter._query_hunter = AsyncMock(return_value={\"email\": \"a@b.com\", \"score\": 99})\n    email, score = await hunter.email_finder(domain=\"example.com\", full_name=\"A B\")\n    assert email == \"a@b.com\"\n    assert score == 99\n    await hunter.aclose()\n\n\n@pytest.mark.asyncio\nasync def test_async_account_information_adds_calls_left():\n    hunter = AsyncPyHunter(\"key\")\n    hunter._query_hunter = AsyncMock(\n        return_value={\"calls\": {\"available\": 200, \"used\": 20}}\n    )\n    data = await hunter.account_information()\n    assert data[\"calls\"][\"left\"] == 180\n    await hunter.aclose()\n\n\n@pytest.mark.asyncio\nasync def test_async_invalid_payload_raises_api_error():\n    transport = httpx.MockTransport(\n        lambda _: httpx.Response(200, json={\"meta\": {\"ok\": True}})\n    )\n    client = httpx.AsyncClient(transport=transport, timeout=5)\n    hunter = AsyncPyHunter(\"key\", client=client, max_retries=0)\n    with pytest.raises(HunterApiError):\n        await hunter._query_hunter(\"https://api.hunter.io/v2/account\", {\"api_key\": \"key\"})\n    await hunter.aclose()\n\n\n@pytest.mark.asyncio\nasync def test_async_logo_returns_content_bytes():\n    transport = httpx.MockTransport(\n        lambda _: httpx.Response(\n            200,\n            content=b\"binary-logo\",\n            headers={\"content-type\": \"image/png\"},\n        )\n    )\n    client = httpx.AsyncClient(transport=transport, timeout=5)\n    hunter = AsyncPyHunter(\"key\", client=client, max_retries=0)\n    content = await hunter.logo(\"stripe.com\")\n    assert content == b\"binary-logo\"\n    await hunter.aclose()\n"
  },
  {
    "path": "tests/test_parity.py",
    "content": "from unittest.mock import AsyncMock, Mock\n\nimport pytest\n\nfrom pyhunter import AsyncPyHunter, PyHunter\nfrom pyhunter.exceptions import MissingCompanyError\n\n\n@pytest.mark.asyncio\nasync def test_sync_async_domain_search_parity():\n    expected = {\"domain\": \"example.com\", \"emails\": []}\n\n    sync_client = PyHunter(\"key\")\n    sync_client._query_hunter = Mock(return_value=expected)\n\n    async_client = AsyncPyHunter(\"key\")\n    async_client._query_hunter = AsyncMock(return_value=expected)\n\n    sync_data = sync_client.domain_search(domain=\"example.com\")\n    async_data = await async_client.domain_search(domain=\"example.com\")\n\n    assert sync_data == async_data\n    await async_client.aclose()\n\n\n@pytest.mark.asyncio\nasync def test_sync_async_errors_match_for_missing_company():\n    sync_client = PyHunter(\"key\")\n    async_client = AsyncPyHunter(\"key\")\n\n    with pytest.raises(MissingCompanyError):\n        sync_client.email_count()\n    with pytest.raises(MissingCompanyError):\n        await async_client.email_count()\n\n    await async_client.aclose()\n"
  },
  {
    "path": "tests/test_sync_client.py",
    "content": "from unittest.mock import Mock\n\nimport pytest\nimport requests\n\nfrom pyhunter import PyHunter\nfrom pyhunter.exceptions import HunterApiError, MissingCompanyError, MissingNameError\n\n\ndef _mock_response(payload, status_code=200):\n    response = Mock()\n    response.status_code = status_code\n    response.json.return_value = payload\n    response.text = str(payload)\n    response.raise_for_status.return_value = None\n    return response\n\n\ndef test_domain_search_requires_domain_or_company():\n    hunter = PyHunter(\"key\")\n    with pytest.raises(MissingCompanyError):\n        hunter.domain_search()\n\n\ndef test_email_finder_requires_name_when_no_linkedin():\n    hunter = PyHunter(\"key\")\n    with pytest.raises(MissingNameError):\n        hunter.email_finder(domain=\"example.com\")\n\n\ndef test_domain_search_includes_offset_zero():\n    hunter = PyHunter(\"key\")\n    hunter._query_hunter = Mock(return_value={\"emails\": []})\n    hunter.domain_search(domain=\"example.com\", offset=0, limit=0)\n    args = hunter._query_hunter.call_args[0]\n    params = args[1]\n    assert params[\"offset\"] == 0\n    assert params[\"limit\"] == 0\n\n\ndef test_email_finder_returns_tuple():\n    hunter = PyHunter(\"key\")\n    hunter._query_hunter = Mock(return_value={\"email\": \"a@b.com\", \"score\": 92})\n    email, score = hunter.email_finder(domain=\"example.com\", full_name=\"A B\")\n    assert email == \"a@b.com\"\n    assert score == 92\n\n\ndef test_account_information_adds_calls_left():\n    hunter = PyHunter(\"key\")\n    hunter._query_hunter = Mock(\n        return_value={\"calls\": {\"available\": 100, \"used\": 10}}\n    )\n    data = hunter.account_information()\n    assert data[\"calls\"][\"left\"] == 90\n\n\ndef test_query_hunter_raises_hunter_api_error_on_invalid_payload():\n    hunter = PyHunter(\"key\")\n    bad = _mock_response({\"meta\": {}}, 200)\n    hunter.session = Mock()\n    hunter.session.get.return_value = bad\n    with pytest.raises(HunterApiError):\n        hunter._query_hunter(\"https://api.hunter.io/v2/account\", {\"api_key\": \"key\"})\n\n\ndef test_query_hunter_wraps_http_error():\n    hunter = PyHunter(\"key\", max_retries=0)\n    err_response = _mock_response({\"errors\": [{\"id\": \"bad\"}]}, status_code=400)\n    err_response.raise_for_status.side_effect = requests.exceptions.HTTPError(\n        response=err_response\n    )\n    hunter.session = Mock()\n    hunter.session.get.return_value = err_response\n\n    with pytest.raises(HunterApiError) as exc:\n        hunter._query_hunter(\"https://api.hunter.io/v2/account\", {\"api_key\": \"key\"})\n    assert exc.value.status_code == 400\n\n\ndef test_major_categories_call_query_hunter():\n    hunter = PyHunter(\"key\")\n    hunter._query_hunter = Mock(return_value={\"ok\": True})\n\n    hunter.email_enrichment(email=\"a@b.com\")\n    hunter.discover(query=\"tech\")\n    hunter.get_leads(limit=5)\n    hunter.get_campaigns(limit=5)\n\n    assert hunter._query_hunter.call_count == 4\n\n\ndef test_logo_returns_content_bytes():\n    hunter = PyHunter(\"key\")\n    mock_response = Mock()\n    mock_response.raise_for_status.return_value = None\n    mock_response.content = b\"binary-logo\"\n    hunter.session = Mock()\n    hunter.session.get.return_value = mock_response\n\n    logo = hunter.logo(\"stripe.com\")\n\n    assert logo == b\"binary-logo\"\n"
  }
]