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.
Files changed (38) hide show
  1. discolike-0.1.0/.gitignore +6 -0
  2. discolike-0.1.0/PKG-INFO +105 -0
  3. discolike-0.1.0/README.md +80 -0
  4. discolike-0.1.0/pyproject.toml +53 -0
  5. discolike-0.1.0/src/discolike/__init__.py +41 -0
  6. discolike-0.1.0/src/discolike/_client.py +140 -0
  7. discolike-0.1.0/src/discolike/_config.py +58 -0
  8. discolike-0.1.0/src/discolike/_exceptions.py +97 -0
  9. discolike-0.1.0/src/discolike/_jobs.py +98 -0
  10. discolike-0.1.0/src/discolike/_models.py +12 -0
  11. discolike-0.1.0/src/discolike/_transport.py +146 -0
  12. discolike-0.1.0/src/discolike/_version.py +1 -0
  13. discolike-0.1.0/src/discolike/py.typed +0 -0
  14. discolike-0.1.0/src/discolike/resources/__init__.py +0 -0
  15. discolike-0.1.0/src/discolike/resources/_base.py +41 -0
  16. discolike-0.1.0/src/discolike/resources/account.py +24 -0
  17. discolike-0.1.0/src/discolike/resources/companies.py +152 -0
  18. discolike-0.1.0/src/discolike/resources/contacts.py +420 -0
  19. discolike-0.1.0/src/discolike/resources/discogen.py +135 -0
  20. discolike-0.1.0/src/discolike/resources/discovery.py +221 -0
  21. discolike-0.1.0/src/discolike/resources/enrich.py +137 -0
  22. discolike-0.1.0/src/discolike/resources/match.py +119 -0
  23. discolike-0.1.0/src/discolike/resources/queries.py +105 -0
  24. discolike-0.1.0/tests/conftest.py +23 -0
  25. discolike-0.1.0/tests/test_client.py +44 -0
  26. discolike-0.1.0/tests/test_companies.py +54 -0
  27. discolike-0.1.0/tests/test_config.py +65 -0
  28. discolike-0.1.0/tests/test_contacts.py +242 -0
  29. discolike-0.1.0/tests/test_contract_registry.py +59 -0
  30. discolike-0.1.0/tests/test_discogen.py +258 -0
  31. discolike-0.1.0/tests/test_discovery.py +48 -0
  32. discolike-0.1.0/tests/test_enrich.py +151 -0
  33. discolike-0.1.0/tests/test_exceptions.py +70 -0
  34. discolike-0.1.0/tests/test_jobs.py +95 -0
  35. discolike-0.1.0/tests/test_match.py +85 -0
  36. discolike-0.1.0/tests/test_package.py +5 -0
  37. discolike-0.1.0/tests/test_queries.py +109 -0
  38. discolike-0.1.0/tests/test_transport.py +244 -0
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ .venv/
3
+ dist/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ *.egg-info/
@@ -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")