discolike 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- discolike-0.1.0/.gitignore +6 -0
- discolike-0.1.0/PKG-INFO +105 -0
- discolike-0.1.0/README.md +80 -0
- discolike-0.1.0/pyproject.toml +53 -0
- discolike-0.1.0/src/discolike/__init__.py +41 -0
- discolike-0.1.0/src/discolike/_client.py +140 -0
- discolike-0.1.0/src/discolike/_config.py +58 -0
- discolike-0.1.0/src/discolike/_exceptions.py +97 -0
- discolike-0.1.0/src/discolike/_jobs.py +98 -0
- discolike-0.1.0/src/discolike/_models.py +12 -0
- discolike-0.1.0/src/discolike/_transport.py +146 -0
- discolike-0.1.0/src/discolike/_version.py +1 -0
- discolike-0.1.0/src/discolike/py.typed +0 -0
- discolike-0.1.0/src/discolike/resources/__init__.py +0 -0
- discolike-0.1.0/src/discolike/resources/_base.py +41 -0
- discolike-0.1.0/src/discolike/resources/account.py +24 -0
- discolike-0.1.0/src/discolike/resources/companies.py +152 -0
- discolike-0.1.0/src/discolike/resources/contacts.py +420 -0
- discolike-0.1.0/src/discolike/resources/discogen.py +135 -0
- discolike-0.1.0/src/discolike/resources/discovery.py +221 -0
- discolike-0.1.0/src/discolike/resources/enrich.py +137 -0
- discolike-0.1.0/src/discolike/resources/match.py +119 -0
- discolike-0.1.0/src/discolike/resources/queries.py +105 -0
- discolike-0.1.0/tests/conftest.py +23 -0
- discolike-0.1.0/tests/test_client.py +44 -0
- discolike-0.1.0/tests/test_companies.py +54 -0
- discolike-0.1.0/tests/test_config.py +65 -0
- discolike-0.1.0/tests/test_contacts.py +242 -0
- discolike-0.1.0/tests/test_contract_registry.py +59 -0
- discolike-0.1.0/tests/test_discogen.py +258 -0
- discolike-0.1.0/tests/test_discovery.py +48 -0
- discolike-0.1.0/tests/test_enrich.py +151 -0
- discolike-0.1.0/tests/test_exceptions.py +70 -0
- discolike-0.1.0/tests/test_jobs.py +95 -0
- discolike-0.1.0/tests/test_match.py +85 -0
- discolike-0.1.0/tests/test_package.py +5 -0
- discolike-0.1.0/tests/test_queries.py +109 -0
- discolike-0.1.0/tests/test_transport.py +244 -0
discolike-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: discolike
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the DiscoLike API
|
|
5
|
+
Project-URL: Homepage, https://www.discolike.com
|
|
6
|
+
Project-URL: Documentation, https://docs.discolike.com
|
|
7
|
+
Project-URL: Repository, https://github.com/Discolike/discolike-python
|
|
8
|
+
Author-email: DiscoLike <support@discolike.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: business-data,discolike,enrichment,sdk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx>=0.27
|
|
21
|
+
Requires-Dist: pydantic>=2.7
|
|
22
|
+
Provides-Extra: cli
|
|
23
|
+
Requires-Dist: discolike-cli>=0.1.0; extra == 'cli'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# discolike
|
|
27
|
+
|
|
28
|
+
Official Python SDK for the [DiscoLike API](https://www.discolike.com) — discover lookalike companies, enrich domain lists, match company names to domains, and find contacts, from typed Python.
|
|
29
|
+
|
|
30
|
+
For the terminal, see [`discolike-cli`](https://pypi.org/project/discolike-cli/) (`pip install discolike-cli` or `uvx --from discolike-cli discolike`).
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install discolike
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.10+.
|
|
39
|
+
|
|
40
|
+
## Authentication
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export DISCOLIKE_API_KEY="dl_..."
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also pass `api_key=...` explicitly to `Discolike()`.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from discolike import Discolike
|
|
52
|
+
|
|
53
|
+
client = Discolike()
|
|
54
|
+
|
|
55
|
+
companies = client.discover(
|
|
56
|
+
icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection",
|
|
57
|
+
country=["US"],
|
|
58
|
+
max_records=25,
|
|
59
|
+
)
|
|
60
|
+
for company in companies:
|
|
61
|
+
print(company.domain, company.name, company.similarity)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The client is a context manager if you want deterministic cleanup:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
with Discolike() as client:
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Async
|
|
72
|
+
|
|
73
|
+
Every resource has an async twin on `AsyncDiscolike`:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
from discolike import AsyncDiscolike
|
|
78
|
+
|
|
79
|
+
async def main() -> None:
|
|
80
|
+
async with AsyncDiscolike() as client:
|
|
81
|
+
companies = await client.discover(icp_text="B2B SaaS for logistics", max_records=10)
|
|
82
|
+
print([c.domain for c in companies])
|
|
83
|
+
|
|
84
|
+
asyncio.run(main())
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Long-running jobs
|
|
88
|
+
|
|
89
|
+
Bulk operations (`match.bulk`, `segment`, `validate_icp`, `contacts.bulk_match`) return a `Job` handle instead of blocking:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
job = client.segment(domains=["stripe.com", "adyen.com", "checkout.com"])
|
|
93
|
+
result = job.wait()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure.
|
|
97
|
+
|
|
98
|
+
## Links
|
|
99
|
+
|
|
100
|
+
- **API documentation**: [docs.discolike.com](https://docs.discolike.com)
|
|
101
|
+
- **Source**: [github.com/Discolike/discolike-python](https://github.com/Discolike/discolike-python)
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
[MIT](https://github.com/Discolike/discolike-python/blob/main/LICENSE)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# discolike
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [DiscoLike API](https://www.discolike.com) — discover lookalike companies, enrich domain lists, match company names to domains, and find contacts, from typed Python.
|
|
4
|
+
|
|
5
|
+
For the terminal, see [`discolike-cli`](https://pypi.org/project/discolike-cli/) (`pip install discolike-cli` or `uvx --from discolike-cli discolike`).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install discolike
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.10+.
|
|
14
|
+
|
|
15
|
+
## Authentication
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export DISCOLIKE_API_KEY="dl_..."
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also pass `api_key=...` explicitly to `Discolike()`.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from discolike import Discolike
|
|
27
|
+
|
|
28
|
+
client = Discolike()
|
|
29
|
+
|
|
30
|
+
companies = client.discover(
|
|
31
|
+
icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection",
|
|
32
|
+
country=["US"],
|
|
33
|
+
max_records=25,
|
|
34
|
+
)
|
|
35
|
+
for company in companies:
|
|
36
|
+
print(company.domain, company.name, company.similarity)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The client is a context manager if you want deterministic cleanup:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
with Discolike() as client:
|
|
43
|
+
...
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Async
|
|
47
|
+
|
|
48
|
+
Every resource has an async twin on `AsyncDiscolike`:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import asyncio
|
|
52
|
+
from discolike import AsyncDiscolike
|
|
53
|
+
|
|
54
|
+
async def main() -> None:
|
|
55
|
+
async with AsyncDiscolike() as client:
|
|
56
|
+
companies = await client.discover(icp_text="B2B SaaS for logistics", max_records=10)
|
|
57
|
+
print([c.domain for c in companies])
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Long-running jobs
|
|
63
|
+
|
|
64
|
+
Bulk operations (`match.bulk`, `segment`, `validate_icp`, `contacts.bulk_match`) return a `Job` handle instead of blocking:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
job = client.segment(domains=["stripe.com", "adyen.com", "checkout.com"])
|
|
68
|
+
result = job.wait()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure.
|
|
72
|
+
|
|
73
|
+
## Links
|
|
74
|
+
|
|
75
|
+
- **API documentation**: [docs.discolike.com](https://docs.discolike.com)
|
|
76
|
+
- **Source**: [github.com/Discolike/discolike-python](https://github.com/Discolike/discolike-python)
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
[MIT](https://github.com/Discolike/discolike-python/blob/main/LICENSE)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "discolike"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the DiscoLike API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
"pydantic>=2.7",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["discolike", "sdk", "business-data", "enrichment"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
cli = ["discolike-cli>=0.1.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://www.discolike.com"
|
|
34
|
+
Documentation = "https://docs.discolike.com"
|
|
35
|
+
Repository = "https://github.com/Discolike/discolike-python"
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
discolike-cli = { workspace = true }
|
|
39
|
+
|
|
40
|
+
[tool.hatch.version]
|
|
41
|
+
path = "src/discolike/_version.py"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest>=8",
|
|
46
|
+
"pytest-asyncio>=0.24",
|
|
47
|
+
"ruff>=0.8",
|
|
48
|
+
"ty",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
asyncio_mode = "auto"
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from discolike._client import AsyncDiscolike
|
|
2
|
+
from discolike._client import Discolike
|
|
3
|
+
from discolike._exceptions import APIConnectionError
|
|
4
|
+
from discolike._exceptions import AuthenticationError
|
|
5
|
+
from discolike._exceptions import DiscolikeError
|
|
6
|
+
from discolike._exceptions import JobFailedError
|
|
7
|
+
from discolike._exceptions import JobTimeoutError
|
|
8
|
+
from discolike._exceptions import NotFoundError
|
|
9
|
+
from discolike._exceptions import PlanAccessError
|
|
10
|
+
from discolike._exceptions import RateLimitError
|
|
11
|
+
from discolike._exceptions import ServerError
|
|
12
|
+
from discolike._exceptions import ValidationError
|
|
13
|
+
from discolike._jobs import AsyncJob
|
|
14
|
+
from discolike._jobs import Job
|
|
15
|
+
from discolike._jobs import JobStatus
|
|
16
|
+
from discolike._models import DiscolikeModel
|
|
17
|
+
from discolike._version import __version__
|
|
18
|
+
from discolike.resources.discovery import Company
|
|
19
|
+
from discolike.resources.discovery import Count
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"APIConnectionError",
|
|
23
|
+
"AsyncDiscolike",
|
|
24
|
+
"AsyncJob",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"Company",
|
|
27
|
+
"Count",
|
|
28
|
+
"Discolike",
|
|
29
|
+
"DiscolikeError",
|
|
30
|
+
"DiscolikeModel",
|
|
31
|
+
"Job",
|
|
32
|
+
"JobFailedError",
|
|
33
|
+
"JobStatus",
|
|
34
|
+
"JobTimeoutError",
|
|
35
|
+
"NotFoundError",
|
|
36
|
+
"PlanAccessError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"ServerError",
|
|
39
|
+
"ValidationError",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from discolike._config import DEFAULT_BASE_URL
|
|
8
|
+
from discolike._config import resolve_api_key
|
|
9
|
+
from discolike._jobs import AsyncJob
|
|
10
|
+
from discolike._jobs import Job
|
|
11
|
+
from discolike._transport import AsyncTransport
|
|
12
|
+
from discolike._transport import Transport
|
|
13
|
+
from discolike.resources.account import AccountResource
|
|
14
|
+
from discolike.resources.account import AsyncAccountResource
|
|
15
|
+
from discolike.resources.companies import AsyncCompaniesResource
|
|
16
|
+
from discolike.resources.companies import CompaniesResource
|
|
17
|
+
from discolike.resources.contacts import AsyncContactsResource
|
|
18
|
+
from discolike.resources.contacts import ContactsResource
|
|
19
|
+
from discolike.resources.discogen import AsyncDiscogenResource
|
|
20
|
+
from discolike.resources.discogen import AsyncValidateResource
|
|
21
|
+
from discolike.resources.discogen import DiscogenResource
|
|
22
|
+
from discolike.resources.discogen import ValidateResource
|
|
23
|
+
from discolike.resources.discovery import AsyncDiscoveryResource
|
|
24
|
+
from discolike.resources.discovery import Company
|
|
25
|
+
from discolike.resources.discovery import Count
|
|
26
|
+
from discolike.resources.discovery import DiscoveryResource
|
|
27
|
+
from discolike.resources.enrich import AppendResult
|
|
28
|
+
from discolike.resources.enrich import AsyncEnrichResource
|
|
29
|
+
from discolike.resources.enrich import EnrichResource
|
|
30
|
+
from discolike.resources.match import AsyncMatchResource
|
|
31
|
+
from discolike.resources.match import MatchResource
|
|
32
|
+
from discolike.resources.queries import AsyncQueriesResource
|
|
33
|
+
from discolike.resources.queries import QueriesResource
|
|
34
|
+
|
|
35
|
+
DEFAULT_TIMEOUT_SECONDS = 60.0
|
|
36
|
+
DEFAULT_MAX_RETRIES = 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Discolike:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
45
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
46
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
47
|
+
http_client: httpx.Client | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._transport = Transport(
|
|
50
|
+
resolve_api_key(api_key),
|
|
51
|
+
base_url=base_url,
|
|
52
|
+
timeout=timeout,
|
|
53
|
+
max_retries=max_retries,
|
|
54
|
+
http_client=http_client,
|
|
55
|
+
)
|
|
56
|
+
self.account = AccountResource(self._transport)
|
|
57
|
+
self.companies = CompaniesResource(self._transport)
|
|
58
|
+
self.contacts = ContactsResource(self._transport)
|
|
59
|
+
self.match = MatchResource(self._transport)
|
|
60
|
+
self.discogen = DiscogenResource(self._transport)
|
|
61
|
+
self.queries = QueriesResource(self._transport)
|
|
62
|
+
self._discovery = DiscoveryResource(self._transport)
|
|
63
|
+
self._validate = ValidateResource(self._transport)
|
|
64
|
+
self._enrich = EnrichResource(self._transport)
|
|
65
|
+
|
|
66
|
+
def discover(self, **kwargs: Any) -> list[Company]: # noqa: ANN401 -- forwards to DiscoveryResource.discover's typed signature
|
|
67
|
+
return self._discovery.discover(**kwargs)
|
|
68
|
+
|
|
69
|
+
def count(self, **kwargs: Any) -> Count: # noqa: ANN401 -- forwards to DiscoveryResource.count's typed signature
|
|
70
|
+
return self._discovery.count(**kwargs)
|
|
71
|
+
|
|
72
|
+
def validate_icp(self, **kwargs: Any) -> Job: # noqa: ANN401 -- forwards to ValidateResource.icp's typed signature
|
|
73
|
+
return self._validate.icp(**kwargs)
|
|
74
|
+
|
|
75
|
+
def append(self, **kwargs: Any) -> list[AppendResult] | bytes: # noqa: ANN401 -- forwards to EnrichResource.append's typed signature
|
|
76
|
+
return self._enrich.append(**kwargs)
|
|
77
|
+
|
|
78
|
+
def segment(self, **kwargs: Any) -> Job: # noqa: ANN401 -- forwards to EnrichResource.segment's typed signature
|
|
79
|
+
return self._enrich.segment(**kwargs)
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
self._transport.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> Discolike:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AsyncDiscolike:
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
api_key: str | None = None,
|
|
96
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
97
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
98
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
99
|
+
http_client: httpx.AsyncClient | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._transport = AsyncTransport(
|
|
102
|
+
resolve_api_key(api_key),
|
|
103
|
+
base_url=base_url,
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
max_retries=max_retries,
|
|
106
|
+
http_client=http_client,
|
|
107
|
+
)
|
|
108
|
+
self.account = AsyncAccountResource(self._transport)
|
|
109
|
+
self.companies = AsyncCompaniesResource(self._transport)
|
|
110
|
+
self.contacts = AsyncContactsResource(self._transport)
|
|
111
|
+
self.match = AsyncMatchResource(self._transport)
|
|
112
|
+
self.discogen = AsyncDiscogenResource(self._transport)
|
|
113
|
+
self.queries = AsyncQueriesResource(self._transport)
|
|
114
|
+
self._discovery = AsyncDiscoveryResource(self._transport)
|
|
115
|
+
self._validate = AsyncValidateResource(self._transport)
|
|
116
|
+
self._enrich = AsyncEnrichResource(self._transport)
|
|
117
|
+
|
|
118
|
+
async def discover(self, **kwargs: Any) -> list[Company]: # noqa: ANN401 -- forwards to AsyncDiscoveryResource.discover's typed signature
|
|
119
|
+
return await self._discovery.discover(**kwargs)
|
|
120
|
+
|
|
121
|
+
async def count(self, **kwargs: Any) -> Count: # noqa: ANN401 -- forwards to AsyncDiscoveryResource.count's typed signature
|
|
122
|
+
return await self._discovery.count(**kwargs)
|
|
123
|
+
|
|
124
|
+
async def validate_icp(self, **kwargs: Any) -> AsyncJob: # noqa: ANN401 -- forwards to AsyncValidateResource.icp's typed signature
|
|
125
|
+
return await self._validate.icp(**kwargs)
|
|
126
|
+
|
|
127
|
+
async def append(self, **kwargs: Any) -> list[AppendResult] | bytes: # noqa: ANN401 -- forwards to AsyncEnrichResource.append's typed signature
|
|
128
|
+
return await self._enrich.append(**kwargs)
|
|
129
|
+
|
|
130
|
+
async def segment(self, **kwargs: Any) -> AsyncJob: # noqa: ANN401 -- forwards to AsyncEnrichResource.segment's typed signature
|
|
131
|
+
return await self._enrich.segment(**kwargs)
|
|
132
|
+
|
|
133
|
+
async def aclose(self) -> None:
|
|
134
|
+
await self._transport.aclose()
|
|
135
|
+
|
|
136
|
+
async def __aenter__(self) -> AsyncDiscolike:
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
140
|
+
await self.aclose()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from discolike._exceptions import AuthenticationError
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.discolike.com/v1"
|
|
11
|
+
ENV_API_KEY = "DISCOLIKE_API_KEY" # foxguard: ignore[py/no-hardcoded-secret]
|
|
12
|
+
KEYS_URL = "https://app.discolike.com/account/management/keys"
|
|
13
|
+
|
|
14
|
+
_NO_KEY_MESSAGE = (
|
|
15
|
+
"No API key found. Set the DISCOLIKE_API_KEY environment variable, pass api_key=..., "
|
|
16
|
+
f"or run `discolike auth login`. Create a key at {KEYS_URL}"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
22
|
+
return Path(base) / "discolike" / "config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_config() -> dict[str, Any]:
|
|
26
|
+
path = config_path()
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return {}
|
|
29
|
+
try:
|
|
30
|
+
loaded = json.loads(path.read_text())
|
|
31
|
+
except (ValueError, OSError):
|
|
32
|
+
return {}
|
|
33
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
37
|
+
path = config_path()
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
40
|
+
with os.fdopen(fd, "w") as handle:
|
|
41
|
+
handle.write(json.dumps(config, indent=2) + "\n")
|
|
42
|
+
path.chmod(0o600)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def delete_config() -> None:
|
|
46
|
+
config_path().unlink(missing_ok=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_api_key(explicit: str | None = None) -> str:
|
|
50
|
+
if explicit:
|
|
51
|
+
return explicit
|
|
52
|
+
from_env = os.environ.get(ENV_API_KEY)
|
|
53
|
+
if from_env:
|
|
54
|
+
return from_env
|
|
55
|
+
from_file = load_config().get("api_key")
|
|
56
|
+
if from_file:
|
|
57
|
+
return str(from_file)
|
|
58
|
+
raise AuthenticationError(_NO_KEY_MESSAGE)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DiscolikeError(Exception):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
message: str,
|
|
13
|
+
*,
|
|
14
|
+
status_code: int | None = None,
|
|
15
|
+
payload: Any = None, # noqa: ANN401 -- decoded JSON body, shape is server-defined
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.payload = payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthenticationError(DiscolikeError): ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PlanAccessError(DiscolikeError): ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ValidationError(DiscolikeError): ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotFoundError(DiscolikeError): ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ServerError(DiscolikeError): ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class APIConnectionError(DiscolikeError): ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JobFailedError(DiscolikeError): ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class JobTimeoutError(DiscolikeError): ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RateLimitError(DiscolikeError):
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message: str,
|
|
50
|
+
*,
|
|
51
|
+
status_code: int | None = None,
|
|
52
|
+
payload: Any = None, # noqa: ANN401 -- decoded JSON body, shape is server-defined
|
|
53
|
+
retry_after: float | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(message, status_code=status_code, payload=payload)
|
|
56
|
+
self.retry_after = retry_after
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_STATUS_MAP: dict[int, type[DiscolikeError]] = {
|
|
60
|
+
400: ValidationError,
|
|
61
|
+
401: AuthenticationError,
|
|
62
|
+
402: PlanAccessError,
|
|
63
|
+
403: PlanAccessError,
|
|
64
|
+
404: NotFoundError,
|
|
65
|
+
422: ValidationError,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _extract_message(response: httpx.Response) -> tuple[str, Any]:
|
|
70
|
+
try:
|
|
71
|
+
payload = response.json()
|
|
72
|
+
except ValueError:
|
|
73
|
+
return response.text[:500] or f"HTTP {response.status_code}", None
|
|
74
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
75
|
+
if isinstance(detail, str):
|
|
76
|
+
return detail, payload
|
|
77
|
+
if isinstance(detail, list):
|
|
78
|
+
parts = [
|
|
79
|
+
f"{'.'.join(str(x) for x in item.get('loc', []))}: {item.get('msg', '')}"
|
|
80
|
+
if isinstance(item, dict)
|
|
81
|
+
else str(item)
|
|
82
|
+
for item in detail
|
|
83
|
+
]
|
|
84
|
+
return "; ".join(parts) or f"HTTP {response.status_code}", payload
|
|
85
|
+
return json.dumps(payload)[:500], payload
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def raise_for_status(response: httpx.Response) -> None:
|
|
89
|
+
if response.status_code < 400:
|
|
90
|
+
return
|
|
91
|
+
message, payload = _extract_message(response)
|
|
92
|
+
if response.status_code == 429:
|
|
93
|
+
header = response.headers.get("Retry-After")
|
|
94
|
+
retry_after = float(header) if header and header.replace(".", "", 1).isdigit() else None
|
|
95
|
+
raise RateLimitError(message, status_code=429, payload=payload, retry_after=retry_after)
|
|
96
|
+
exc_type = _STATUS_MAP.get(response.status_code, ServerError if response.status_code >= 500 else DiscolikeError)
|
|
97
|
+
raise exc_type(message, status_code=response.status_code, payload=payload)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pydantic
|
|
9
|
+
|
|
10
|
+
from discolike._exceptions import JobFailedError
|
|
11
|
+
from discolike._exceptions import JobTimeoutError
|
|
12
|
+
from discolike._models import DiscolikeModel
|
|
13
|
+
from discolike._transport import AsyncTransport
|
|
14
|
+
from discolike._transport import Transport
|
|
15
|
+
|
|
16
|
+
TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
|
17
|
+
FAMILY_DISCOGEN = "discogen"
|
|
18
|
+
FAMILY_BULKMATCH = "bulkmatch"
|
|
19
|
+
FAMILY_CONTACTMATCH = "contactmatch"
|
|
20
|
+
FAMILY_SEGMENT = "segment"
|
|
21
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0
|
|
22
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 5.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JobStatus(DiscolikeModel):
|
|
26
|
+
status: str
|
|
27
|
+
progress: int | None = None
|
|
28
|
+
results: Any = None
|
|
29
|
+
result: Any = None
|
|
30
|
+
warnings: list[str] = pydantic.Field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Job:
|
|
34
|
+
def __init__(self, transport: Transport, *, task_family: str, task_id: str) -> None:
|
|
35
|
+
self._transport = transport
|
|
36
|
+
self.task_family = task_family
|
|
37
|
+
self.task_id = task_id
|
|
38
|
+
|
|
39
|
+
def status(self) -> JobStatus:
|
|
40
|
+
response = self._transport.request("GET", f"/{self.task_family}/status/{self.task_id}")
|
|
41
|
+
return JobStatus.model_validate(response.json())
|
|
42
|
+
|
|
43
|
+
def cancel(self) -> None:
|
|
44
|
+
self._transport.request("DELETE", f"/{self.task_family}/cancel/{self.task_id}")
|
|
45
|
+
|
|
46
|
+
def wait(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
50
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
51
|
+
on_poll: Callable[[JobStatus], None] | None = None,
|
|
52
|
+
) -> JobStatus:
|
|
53
|
+
deadline = time.monotonic() + timeout
|
|
54
|
+
while True:
|
|
55
|
+
current = self.status()
|
|
56
|
+
if on_poll is not None:
|
|
57
|
+
on_poll(current)
|
|
58
|
+
if current.status == "failed":
|
|
59
|
+
raise JobFailedError(str(current.result or "task failed"), payload=current.to_dict())
|
|
60
|
+
if current.status in TERMINAL_STATUSES:
|
|
61
|
+
return current
|
|
62
|
+
if time.monotonic() >= deadline:
|
|
63
|
+
raise JobTimeoutError(f"Task {self.task_id} did not finish within {timeout:.0f}s")
|
|
64
|
+
time.sleep(poll_interval)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AsyncJob:
|
|
68
|
+
def __init__(self, transport: AsyncTransport, *, task_family: str, task_id: str) -> None:
|
|
69
|
+
self._transport = transport
|
|
70
|
+
self.task_family = task_family
|
|
71
|
+
self.task_id = task_id
|
|
72
|
+
|
|
73
|
+
async def status(self) -> JobStatus:
|
|
74
|
+
response = await self._transport.request("GET", f"/{self.task_family}/status/{self.task_id}")
|
|
75
|
+
return JobStatus.model_validate(response.json())
|
|
76
|
+
|
|
77
|
+
async def cancel(self) -> None:
|
|
78
|
+
await self._transport.request("DELETE", f"/{self.task_family}/cancel/{self.task_id}")
|
|
79
|
+
|
|
80
|
+
async def wait(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
84
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
85
|
+
on_poll: Callable[[JobStatus], None] | None = None,
|
|
86
|
+
) -> JobStatus:
|
|
87
|
+
deadline = time.monotonic() + timeout
|
|
88
|
+
while True:
|
|
89
|
+
current = await self.status()
|
|
90
|
+
if on_poll is not None:
|
|
91
|
+
on_poll(current)
|
|
92
|
+
if current.status == "failed":
|
|
93
|
+
raise JobFailedError(str(current.result or "task failed"), payload=current.to_dict())
|
|
94
|
+
if current.status in TERMINAL_STATUSES:
|
|
95
|
+
return current
|
|
96
|
+
if time.monotonic() >= deadline:
|
|
97
|
+
raise JobTimeoutError(f"Task {self.task_id} did not finish within {timeout:.0f}s")
|
|
98
|
+
await asyncio.sleep(poll_interval)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DiscolikeModel(pydantic.BaseModel):
|
|
9
|
+
model_config = pydantic.ConfigDict(extra="allow")
|
|
10
|
+
|
|
11
|
+
def to_dict(self) -> dict[str, Any]:
|
|
12
|
+
return self.model_dump(mode="json")
|