From 3c11530143229bca154eb51751948c682621d179 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:15:48 -0700 Subject: [PATCH 01/28] feat(sdk): add DiscolikeRequest base for generated request models Request models need to send exactly what the caller set: server defaults must keep governing omitted fields, and PUT /llm-providers relies on an explicit null api_key meaning "keep the stored key", so to_wire() excludes unset fields rather than None values. Unknown fields pass through so the SDK can lag the platform without blocking callers. datamodel-code-generator joins the SDK dev group for the generator. --- packages/discolike/pyproject.toml | 1 + packages/discolike/src/discolike/__init__.py | 2 + packages/discolike/src/discolike/_models.py | 7 + packages/discolike/tests/test_models.py | 38 ++ uv.lock | 369 ++++++++++++++++++- 5 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 packages/discolike/tests/test_models.py diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 875085c..83c6ac5 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -51,6 +51,7 @@ path = "src/discolike/_version.py" [dependency-groups] dev = [ + "datamodel-code-generator>=0.75,<0.76", "discolike-testkit", "pytest>=8", "pytest-asyncio>=0.24", diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index 982165a..7f4b96b 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -14,6 +14,7 @@ from discolike._jobs import Job from discolike._jobs import JobStatus from discolike._models import DiscolikeModel +from discolike._models import DiscolikeRequest from discolike._version import __version__ from discolike.resources.discovery import Company from discolike.resources.discovery import Count @@ -33,6 +34,7 @@ "Discolike", "DiscolikeError", "DiscolikeModel", + "DiscolikeRequest", "EmailBatchResults", "EmailJobResult", "EnumerationMatch", diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 6734ee2..d400be6 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -10,3 +10,10 @@ class DiscolikeModel(pydantic.BaseModel): def to_dict(self) -> dict[str, Any]: return self.model_dump(mode="json") + + +class DiscolikeRequest(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="allow", populate_by_name=True) + + def to_wire(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude_unset=True, by_alias=True) diff --git a/packages/discolike/tests/test_models.py b/packages/discolike/tests/test_models.py new file mode 100644 index 0000000..1b8b12a --- /dev/null +++ b/packages/discolike/tests/test_models.py @@ -0,0 +1,38 @@ +import pydantic +import pytest + +from discolike._models import DiscolikeRequest + + +class _Probe(DiscolikeRequest): + name: str + city: str | None = None + limit: int = 10 + zip_code: str | None = pydantic.Field(default=None, alias="zip") + + +def test_to_wire_sends_only_fields_that_were_set() -> None: + assert _Probe(name="Acme").to_wire() == {"name": "Acme"} + + +def test_to_wire_keeps_an_explicit_none() -> None: + assert _Probe(name="Acme", city=None).to_wire() == {"name": "Acme", "city": None} + + +def test_to_wire_passes_unknown_fields_through() -> None: + assert _Probe.model_validate({"name": "Acme", "bogus": 1}).to_wire() == {"name": "Acme", "bogus": 1} + + +def test_populate_by_name_accepts_the_field_name_and_dumps_the_alias() -> None: + assert _Probe(name="Acme", zip_code="78701").to_wire() == {"name": "Acme", "zip": "78701"} + + +def test_missing_required_field_raises_validation_error() -> None: + with pytest.raises(pydantic.ValidationError): + _Probe.model_validate({}) + + +def test_discolike_request_is_exported_from_the_package() -> None: + import discolike + + assert discolike.DiscolikeRequest is DiscolikeRequest diff --git a/uv.lock b/uv.lock index dd2e735..2ae6af7 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' or sys_platform != 'emscripten'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten'", + "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", ] [manifest] @@ -45,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -54,6 +65,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -63,6 +127,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "datamodel-code-generator" +version = "0.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/d2/187e101ed143f5280079a57c7c7e3d702609826aadd85c095fd5a8267af9/datamodel_code_generator-0.75.1.tar.gz", hash = "sha256:d2053a889161af7e495a3eedb92b68c81848e6ea7988d467c602e5b2da30f018", size = 2116531, upload-time = "2026-08-24T19:16:37.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/be/a8d2b7450762a4ebb7b81a8e7b1756af725b3f3c14f3f76599d42493c602/datamodel_code_generator-0.75.1-py3-none-any.whl", hash = "sha256:58cb9330cdf0a356aee8547c0cce52d8fd2daa5253c7a6b8e245e9d3117c999e", size = 609533, upload-time = "2026-08-24T19:16:35.482Z" }, +] + [[package]] name = "discolike" source = { editable = "packages/discolike" } @@ -79,6 +163,7 @@ cli = [ [package.dev-dependencies] dev = [ + { name = "datamodel-code-generator" }, { name = "discolike-testkit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -97,6 +182,7 @@ provides-extras = ["cli"] [package.metadata.requires-dev] dev = [ + { name = "datamodel-code-generator", specifier = ">=0.75,<0.76" }, { name = "discolike-testkit", editable = "packages/discolike-testkit" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.24" }, @@ -168,6 +254,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "genson" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908, upload-time = "2026-07-06T08:21:50.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -225,6 +320,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -234,6 +342,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -246,6 +375,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -255,6 +469,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -264,6 +496,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/bb/ebc6636e1ae41314f796ebb7215fd28febb45f9aac72f2b04cb74b5071dc/platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d", size = 34079, upload-time = "2026-08-24T14:53:49.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/be/0ff05fcd2938fb58ad9219bd54135968342d214737e012d62d43f06a2dd6/platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586", size = 23741, upload-time = "2026-08-24T14:53:48.406Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -445,6 +695,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -580,6 +933,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/8f/ac36fde77e223297454c1e0aeb8888c169eaacf3163bb609e3af942c88cb/ty-0.0.59-py3-none-win_arm64.whl", hash = "sha256:987043ee9e021f49493d9135891ac69c1affeee0d4ad4480c5fa4d9c975fc91b", size = 11650921, upload-time = "2026-07-12T20:22:00.348Z" }, ] +[[package]] +name = "typeguard" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, +] + [[package]] name = "typer" version = "0.26.8" From e3b967625d2d6661d398a6f62309a7c7370c4363 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:21:37 -0700 Subject: [PATCH 02/28] refactor(sdk): drop ignore_params from api_route The two users of ignore_params existed only to hide a kwarg-to-body rename (email.find_batch) and a shared wrapper over two routes (segment). Both go away once request models carry the exact field names, so the stamp shrinks to (method, path, openapi). Moved the two per-route exclusions into check_contract.py's global IGNORE_PARAMS set so the contract check keeps passing until Task 14 rewrites the request side. --- .../src/discolike/resources/_base.py | 9 +++--- .../src/discolike/resources/email.py | 4 +-- .../src/discolike/resources/enrich.py | 4 +-- packages/discolike/tests/test_client.py | 2 +- packages/discolike/tests/test_contacts.py | 14 ++++---- packages/discolike/tests/test_discogen.py | 8 ++--- packages/discolike/tests/test_email.py | 4 +-- packages/discolike/tests/test_enrich.py | 6 ++-- packages/discolike/tests/test_providers.py | 32 +++++-------------- packages/discolike/tests/test_queries.py | 10 +++--- scripts/check_contract.py | 6 ++-- 11 files changed, 42 insertions(+), 57 deletions(-) diff --git a/packages/discolike/src/discolike/resources/_base.py b/packages/discolike/src/discolike/resources/_base.py index 7f677da..edc0945 100644 --- a/packages/discolike/src/discolike/resources/_base.py +++ b/packages/discolike/src/discolike/resources/_base.py @@ -10,17 +10,18 @@ from discolike._transport import Transport F = TypeVar("F", bound=Callable[..., Any]) +FileInput = pathlib.Path | str | BinaryIO -def api_route(method: str, path: str, *, openapi: bool = True, ignore_params: tuple[str, ...] = ()) -> Callable[[F], F]: +def api_route(method: str, path: str, *, openapi: bool = True) -> Callable[[F], F]: def stamp(fn: F) -> F: - fn.__discolike_route__ = (method, path, openapi, ignore_params) # ty: ignore[unresolved-attribute] + fn.__discolike_route__ = (method, path, openapi) # ty: ignore[unresolved-attribute] return fn return stamp -def get_discolike_route(fn: object) -> tuple[str, str, bool, tuple[str, ...]] | None: +def get_discolike_route(fn: object) -> tuple[str, str, bool] | None: return getattr(fn, "__discolike_route__", None) @@ -34,7 +35,7 @@ def __init__(self, transport: AsyncTransport) -> None: self._transport = transport -def open_upload(file: pathlib.Path | str | BinaryIO) -> tuple[str, BinaryIO, bool]: +def open_upload(file: FileInput) -> tuple[str, BinaryIO, bool]: if isinstance(file, (str, pathlib.Path)): path = pathlib.Path(file) return path.name, open(path, "rb"), True # foxguard: ignore[py/no-path-traversal] diff --git a/packages/discolike/src/discolike/resources/email.py b/packages/discolike/src/discolike/resources/email.py index 5b0c3c4..719d43f 100644 --- a/packages/discolike/src/discolike/resources/email.py +++ b/packages/discolike/src/discolike/resources/email.py @@ -37,7 +37,7 @@ def find(self, *, first_name: str, last_name: str, domain: str, known_pattern: s response = self._transport.request("POST", "/email/find", json_body=drop_none(body)) return EmailJob(self._transport, job_id=response.json()["job_id"], kind="find") - @api_route("POST", "/email/find/batch", ignore_params=("contacts",)) + @api_route("POST", "/email/find/batch") def find_batch(self, *, contacts: list[dict[str, str]]) -> EmailBatch: response = self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) return EmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") @@ -58,7 +58,7 @@ async def find( response = await self._transport.request("POST", "/email/find", json_body=drop_none(body)) return AsyncEmailJob(self._transport, job_id=response.json()["job_id"], kind="find") - @api_route("POST", "/email/find/batch", ignore_params=("contacts",)) + @api_route("POST", "/email/find/batch") async def find_batch(self, *, contacts: list[dict[str, str]]) -> AsyncEmailBatch: response = await self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) return AsyncEmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") diff --git a/packages/discolike/src/discolike/resources/enrich.py b/packages/discolike/src/discolike/resources/enrich.py index a26411e..b1ef2d0 100644 --- a/packages/discolike/src/discolike/resources/enrich.py +++ b/packages/discolike/src/discolike/resources/enrich.py @@ -64,7 +64,7 @@ def _segment_file( fh.close() return Job(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - @api_route("GET", "/segment", ignore_params=("domain_column",)) + @api_route("GET", "/segment") def segment( self, *, @@ -137,7 +137,7 @@ async def _segment_file( fh.close() return AsyncJob(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - @api_route("GET", "/segment", ignore_params=("domain_column",)) + @api_route("GET", "/segment") async def segment( self, *, diff --git a/packages/discolike/tests/test_client.py b/packages/discolike/tests/test_client.py index 8dfd2d8..9232cbe 100644 --- a/packages/discolike/tests/test_client.py +++ b/packages/discolike/tests/test_client.py @@ -41,7 +41,7 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.account import AccountResource - assert get_discolike_route(AccountResource.usage) == ("GET", "/usage", True, ()) + assert get_discolike_route(AccountResource.usage) == ("GET", "/usage", True) def test_with_options_timeout_applies_only_to_the_view(make_client: ClientFactory) -> None: diff --git a/packages/discolike/tests/test_contacts.py b/packages/discolike/tests/test_contacts.py index 2ef38f4..50a661d 100644 --- a/packages/discolike/tests/test_contacts.py +++ b/packages/discolike/tests/test_contacts.py @@ -249,10 +249,10 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.contacts import ContactsResource - assert get_discolike_route(ContactsResource.search) == ("GET", "/contacts", True, ()) - assert get_discolike_route(ContactsResource.count) == ("GET", "/contacts/count", True, ()) - assert get_discolike_route(ContactsResource.lookup) == ("GET", "/contacts/lookup", True, ()) - assert get_discolike_route(ContactsResource.match) == ("GET", "/contacts/match", True, ()) - assert get_discolike_route(ContactsResource.bulk_match) == ("POST", "/contacts/bulk-match", True, ()) - assert get_discolike_route(ContactsResource.discover) == ("POST", "/contacts/discover", True, ()) - assert get_discolike_route(ContactsResource.generate) == ("POST", "/contacts/discover/generate", True, ()) + assert get_discolike_route(ContactsResource.search) == ("GET", "/contacts", True) + assert get_discolike_route(ContactsResource.count) == ("GET", "/contacts/count", True) + assert get_discolike_route(ContactsResource.lookup) == ("GET", "/contacts/lookup", True) + assert get_discolike_route(ContactsResource.match) == ("GET", "/contacts/match", True) + assert get_discolike_route(ContactsResource.bulk_match) == ("POST", "/contacts/bulk-match", True) + assert get_discolike_route(ContactsResource.discover) == ("POST", "/contacts/discover", True) + assert get_discolike_route(ContactsResource.generate) == ("POST", "/contacts/discover/generate", True) diff --git a/packages/discolike/tests/test_discogen.py b/packages/discolike/tests/test_discogen.py index eca08a4..8c699cf 100644 --- a/packages/discolike/tests/test_discogen.py +++ b/packages/discolike/tests/test_discogen.py @@ -261,8 +261,8 @@ def test_route_metadata_stamped() -> None: from discolike.resources.discogen import DiscogenResource from discolike.resources.discogen import ValidateResource - assert get_discolike_route(DiscogenResource.process) == ("POST", "/discogen/process", True, ()) - assert get_discolike_route(DiscogenResource.process_personas) == ("POST", "/discogen/process-personas", True, ()) - assert get_discolike_route(DiscogenResource.models) == ("GET", "/discogen/models", True, ()) + assert get_discolike_route(DiscogenResource.process) == ("POST", "/discogen/process", True) + assert get_discolike_route(DiscogenResource.process_personas) == ("POST", "/discogen/process-personas", True) + assert get_discolike_route(DiscogenResource.models) == ("GET", "/discogen/models", True) assert get_discolike_route(DiscogenResource.job) is None - assert get_discolike_route(ValidateResource.icp) == ("POST", "/validate/icp", True, ()) + assert get_discolike_route(ValidateResource.icp) == ("POST", "/validate/icp", True) diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 5cd9867..dc52b7e 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -392,8 +392,8 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.email import EmailResource - assert get_discolike_route(EmailResource.find) == ("POST", "/email/find", True, ()) - assert get_discolike_route(EmailResource.find_batch) == ("POST", "/email/find/batch", True, ("contacts",)) + assert get_discolike_route(EmailResource.find) == ("POST", "/email/find", True) + assert get_discolike_route(EmailResource.find_batch) == ("POST", "/email/find/batch", True) assert get_discolike_route(EmailResource.job) is None assert get_discolike_route(EmailResource.batch) is None diff --git a/packages/discolike/tests/test_enrich.py b/packages/discolike/tests/test_enrich.py index 8bdf041..1264d68 100644 --- a/packages/discolike/tests/test_enrich.py +++ b/packages/discolike/tests/test_enrich.py @@ -146,6 +146,6 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.enrich import EnrichResource - assert get_discolike_route(EnrichResource.append) == ("POST", "/append", True, ()) - assert get_discolike_route(EnrichResource.segment) == ("GET", "/segment", True, ("domain_column",)) - assert get_discolike_route(EnrichResource._segment_file) == ("POST", "/segment", True, ()) + assert get_discolike_route(EnrichResource.append) == ("POST", "/append", True) + assert get_discolike_route(EnrichResource.segment) == ("GET", "/segment", True) + assert get_discolike_route(EnrichResource._segment_file) == ("POST", "/segment", True) diff --git a/packages/discolike/tests/test_providers.py b/packages/discolike/tests/test_providers.py index 184e699..ff1ddd9 100644 --- a/packages/discolike/tests/test_providers.py +++ b/packages/discolike/tests/test_providers.py @@ -388,57 +388,41 @@ def test_route_metadata_stamped() -> None: from discolike.resources.providers import LLMProvidersResource from discolike.resources.providers import SearchProvidersResource - assert get_discolike_route(SearchProvidersResource.list) == ("GET", "/search-providers", True, ()) - assert get_discolike_route(SearchProvidersResource.create) == ("POST", "/search-providers", True, ()) - assert get_discolike_route(SearchProvidersResource.update) == ( - "PUT", - "/search-providers/{integration_id}", - True, - (), - ) + assert get_discolike_route(SearchProvidersResource.list) == ("GET", "/search-providers", True) + assert get_discolike_route(SearchProvidersResource.create) == ("POST", "/search-providers", True) + assert get_discolike_route(SearchProvidersResource.update) == ("PUT", "/search-providers/{integration_id}", True) assert get_discolike_route(SearchProvidersResource.delete) == ( "DELETE", "/search-providers/{integration_id}", True, - (), ) assert get_discolike_route(SearchProvidersResource.set_default) == ( "PUT", "/search-providers/{integration_id}/default", True, - (), ) assert get_discolike_route(SearchProvidersResource.clear_default) == ( "DELETE", "/search-providers/{integration_id}/default", True, - (), - ) - assert get_discolike_route(SearchProvidersResource.models) == ("GET", "/search-providers/models", True, ()) - assert get_discolike_route(LLMProvidersResource.list) == ("GET", "/llm-providers/config", True, ()) - assert get_discolike_route(LLMProvidersResource.create) == ("POST", "/llm-providers/config", True, ()) - assert get_discolike_route(LLMProvidersResource.get) == ("GET", "/llm-providers/config/{integration_id}", True, ()) - assert get_discolike_route(LLMProvidersResource.update) == ( - "PUT", - "/llm-providers/config/{integration_id}", - True, - (), ) + assert get_discolike_route(SearchProvidersResource.models) == ("GET", "/search-providers/models", True) + assert get_discolike_route(LLMProvidersResource.list) == ("GET", "/llm-providers/config", True) + assert get_discolike_route(LLMProvidersResource.create) == ("POST", "/llm-providers/config", True) + assert get_discolike_route(LLMProvidersResource.get) == ("GET", "/llm-providers/config/{integration_id}", True) + assert get_discolike_route(LLMProvidersResource.update) == ("PUT", "/llm-providers/config/{integration_id}", True) assert get_discolike_route(LLMProvidersResource.delete) == ( "DELETE", "/llm-providers/config/{integration_id}", True, - (), ) assert get_discolike_route(LLMProvidersResource.set_default) == ( "POST", "/llm-providers/config/{integration_id}/set-default", True, - (), ) assert get_discolike_route(LLMProvidersResource.test_connection) == ( "POST", "/llm-providers/test-connection", True, - (), ) diff --git a/packages/discolike/tests/test_queries.py b/packages/discolike/tests/test_queries.py index 4711653..e4d5a90 100644 --- a/packages/discolike/tests/test_queries.py +++ b/packages/discolike/tests/test_queries.py @@ -135,8 +135,8 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.queries import QueriesResource - assert get_discolike_route(QueriesResource.list) == ("GET", "/queries/saved", True, ()) - assert get_discolike_route(QueriesResource.create_exclusion_list) == ("POST", "/queries/exclusion-list", True, ()) - assert get_discolike_route(QueriesResource.update) == ("PATCH", "/queries/{query_id}", True, ()) - assert get_discolike_route(QueriesResource.delete) == ("DELETE", "/queries/{query_id}", True, ()) - assert get_discolike_route(QueriesResource.save_results) == ("POST", "/queries/save-results", True, ()) + assert get_discolike_route(QueriesResource.list) == ("GET", "/queries/saved", True) + assert get_discolike_route(QueriesResource.create_exclusion_list) == ("POST", "/queries/exclusion-list", True) + assert get_discolike_route(QueriesResource.update) == ("PATCH", "/queries/{query_id}", True) + assert get_discolike_route(QueriesResource.delete) == ("DELETE", "/queries/{query_id}", True) + assert get_discolike_route(QueriesResource.save_results) == ("POST", "/queries/save-results", True) diff --git a/scripts/check_contract.py b/scripts/check_contract.py index a518417..38f37af 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -26,7 +26,7 @@ from discolike.resources.match import MatchResponse from discolike.resources.queries import SavedQueries -IGNORE_PARAMS = {"file"} +IGNORE_PARAMS = {"file", "contacts", "domain_column"} # SDK response model -> the OpenAPI component schema it mirrors. Anything listed here is # checked field-by-field against the spec, so a platform-side model change surfaces as a @@ -81,11 +81,11 @@ def collect_routes() -> list[RouteEntry]: route = get_discolike_route(member) if route is None: continue - http_method, path, openapi, ignore_params = route + http_method, path, openapi = route key = (http_method, path) if key in seen: continue - excluded = IGNORE_PARAMS | set(ignore_params) + excluded = IGNORE_PARAMS params = tuple( name for name, param in inspect.signature(member).parameters.items() From 827f8eecccea3aa38eed07409a97d3b264baf717 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:38:21 -0700 Subject: [PATCH 03/28] build: add gen_requests.py to derive request models from the spec The platform's pydantic request models import geo/propelauth/litellm and ship no shared package, so the SDK generates its own from the OpenAPI spec instead. Query-param routes get a synthesized Params schema; JSON-body routes reuse the platform component name. anyOf-null and per-item constraints are folded so datamodel-codegen emits plain annotated fields instead of RootModel wrappers, and deprecated params stay out as they have since 0.1.1. --- packages/discolike/tests/test_gen_requests.py | 218 ++++++++++++++ scripts/gen_requests.py | 272 ++++++++++++++++++ 2 files changed, 490 insertions(+) create mode 100644 packages/discolike/tests/test_gen_requests.py create mode 100644 scripts/gen_requests.py diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py new file mode 100644 index 0000000..4596d25 --- /dev/null +++ b/packages/discolike/tests/test_gen_requests.py @@ -0,0 +1,218 @@ +import importlib.util +import pathlib +import sys +from typing import Any + +import pytest + +SCRIPT_PATH = pathlib.Path(__file__).parents[3] / "scripts" / "gen_requests.py" + +FAKE_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "paths": { + "/match": { + "get": { + "parameters": [ + {"name": "name", "in": "query", "required": True, "schema": {"type": "string"}}, + { + "name": "city", + "in": "query", + "required": False, + "schema": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + { + "name": "nl_match", + "in": "query", + "required": False, + "deprecated": True, + "schema": {"type": "string"}, + }, + ] + } + }, + "/bulkmatch": { + "post": { + "parameters": [{"name": "name_column", "in": "query", "required": True, "schema": {"type": "string"}}], + "requestBody": { + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_bulk_match"}}} + }, + } + }, + "/email/find/batch": { + "post": { + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FindEmailBatchRequest"}}} + } + } + }, + "/usage": {"get": {"parameters": []}}, + }, + "components": { + "schemas": { + "Body_bulk_match": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + "required": ["file"], + }, + "FindEmailBatchRequest": { + "type": "object", + "properties": { + "requests": {"type": "array", "items": {"$ref": "#/components/schemas/FindEmailRequest"}} + }, + "required": ["requests"], + }, + "FindEmailRequest": { + "type": "object", + "additionalProperties": False, + "properties": { + "first_name": {"type": "string"}, + "known_pattern": { + "anyOf": [{"type": "string", "maxLength": 40}, {"type": "null"}], + "description": "Known pattern", + }, + "legacy": {"type": "string", "deprecated": True}, + }, + "required": ["first_name"], + }, + "Unrelated": {"type": "object", "properties": {"x": {"type": "integer"}}}, + } + }, +} + + +@pytest.fixture(scope="module") +def gen(): + spec = importlib.util.spec_from_file_location("gen_requests", SCRIPT_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def routes(gen): + return [ + gen.Route("MatchResource", "company", "GET", "/match"), + gen.Route("MatchResource", "bulk", "POST", "/bulkmatch"), + gen.Route("EmailResource", "find_batch", "POST", "/email/find/batch"), + gen.Route("AccountResource", "usage", "GET", "/usage"), + ] + + +@pytest.mark.parametrize( + ("class_name", "method_name", "expected"), + [ + ("MatchResource", "company", "MatchCompanyParams"), + ("CompaniesResource", "public_links", "CompaniesPublicLinksParams"), + ("EnrichResource", "segment_file", "SegmentFileParams"), + ("DiscoveryResource", "discover", "DiscoverParams"), + ("ValidateResource", "icp", "IcpParams"), + ], +) +def test_params_model_name(gen, class_name, method_name, expected): + assert gen.params_model_name(class_name=class_name, method_name=method_name) == expected + + +def test_request_schemas_synthesizes_params_from_query_parameters(gen, routes): + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert schemas["MatchCompanyParams"] == { + "type": "object", + "properties": {"name": {"type": "string"}, "city": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, + "required": ["name"], + } + + +def test_request_schemas_skips_multipart_body_and_keeps_query_params(gen, routes): + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert schemas["MatchBulkParams"] == { + "type": "object", + "properties": {"name_column": {"type": "string"}}, + "required": ["name_column"], + } + assert "Body_bulk_match" not in schemas + + +def test_request_schemas_uses_the_json_body_component_name(gen, routes): + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert schemas["FindEmailBatchRequest"] == FAKE_SPEC["components"]["schemas"]["FindEmailBatchRequest"] + + +def test_request_schemas_emits_nothing_for_routes_without_params(gen, routes): + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert "AccountUsageParams" not in schemas + + +def test_request_schemas_fails_loudly_when_a_route_is_missing_from_the_spec(gen): + with pytest.raises(SystemExit, match="GET /nowhere"): + gen.request_schemas(spec=FAKE_SPEC, routes=[gen.Route("X", "y", "GET", "/nowhere")]) + + +def test_prune_keeps_transitive_refs_and_drops_unrelated_schemas(gen, routes): + requested = {"FindEmailBatchRequest": FAKE_SPEC["components"]["schemas"]["FindEmailBatchRequest"]} + assert set(gen.prune(spec=FAKE_SPEC, requested=requested)) == {"FindEmailBatchRequest", "FindEmailRequest"} + + +def test_normalize_schema_inlines_nullable_and_drops_deprecated_and_additional_properties(gen): + normalized = gen.normalize_schema(FAKE_SPEC["components"]["schemas"]["FindEmailRequest"]) + assert "additionalProperties" not in normalized + assert "legacy" not in normalized["properties"] + assert normalized["properties"]["known_pattern"] == { + "type": "string", + "maxLength": 40, + "description": "Known pattern", + "nullable": True, + } + assert normalized["required"] == ["first_name"] + + +def test_normalize_schema_leaves_required_nullable_unions_alone(gen): + schema = { + "type": "object", + "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, + "required": ["api_key"], + } + assert gen.normalize_schema(schema)["properties"]["api_key"] == {"anyOf": [{"type": "string"}, {"type": "null"}]} + + +def test_normalize_schema_strips_scalar_item_constraints(gen): + schema = { + "type": "object", + "properties": {"tags": {"type": "array", "items": {"type": "string", "minLength": 3}, "maxItems": 20}}, + } + assert gen.normalize_schema(schema)["properties"]["tags"] == { + "type": "array", + "items": {"type": "string"}, + "maxItems": 20, + } + + +def test_build_codegen_spec_wraps_pruned_schemas(gen, routes): + codegen_spec = gen.build_codegen_spec(spec=FAKE_SPEC, routes=routes) + assert codegen_spec["paths"] == {} + assert set(codegen_spec["components"]["schemas"]) == { + "MatchCompanyParams", + "MatchBulkParams", + "FindEmailBatchRequest", + "FindEmailRequest", + } + + +def test_compare_returns_zero_when_identical(gen, capsys): + assert gen.compare(committed="a\n", fresh="a\n") == 0 + assert "up to date" in capsys.readouterr().out + + +def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys): + assert gen.compare(committed="a\n", fresh="b\n") == 1 + out = capsys.readouterr().out + assert "-a" in out + assert "+b" in out + assert "gen_requests.py" in out + + +def test_collect_routes_covers_every_stamped_sync_route(gen): + routes = gen.collect_routes() + assert len(routes) == 48 + assert all(not route.class_name.startswith("Async") for route in routes) diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py new file mode 100644 index 0000000..625031b --- /dev/null +++ b/scripts/gen_requests.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import argparse +import copy +import difflib +import importlib +import inspect +import json +import pathlib +import pkgutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from types import ModuleType +from typing import Any + +import httpx2 + +import discolike.resources +from discolike.resources._base import get_discolike_route + +SPEC_URL = "https://api.discolike.com/v1/openapi.json" +REQUEST_TIMEOUT_SECONDS = 30.0 +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +OUTPUT_PATH = REPO_ROOT / "packages" / "discolike" / "src" / "discolike" / "_generated" / "requests.py" +RUFF_CONFIG = REPO_ROOT / "pyproject.toml" +BASE_CLASS = "discolike._models.DiscolikeRequest" +FILE_HEADER = "# Generated by scripts/gen_requests.py from the platform OpenAPI spec. Do not edit by hand." +JSON_MEDIA_TYPE = "application/json" +QUERY_LOCATION = "query" +NULL_SCHEMA = {"type": "null"} +ITEM_KEYS_KEPT = frozenset({"type", "enum", "format", "$ref"}) +CLIENT_LEVEL_RESOURCES = frozenset({"DiscoveryResource", "EnrichResource", "ValidateResource"}) +ASYNC_CLASS_PREFIX = "Async" +RESOURCE_SUFFIX = "Resource" +PARAMS_SUFFIX = "Params" +CODEGEN_ARGS = [ + "--input-file-type", + "openapi", + "--output-model-type", + "pydantic_v2.BaseModel", + "--base-class", + BASE_CLASS, + "--use-annotated", + "--field-constraints", + "--use-standard-collections", + "--use-union-operator", + "--collapse-root-models", + "--enum-field-as-literal", + "all", + "--target-python-version", + "3.10", + "--use-double-quotes", + "--disable-timestamp", + "--formatters", + "builtin", + "--custom-file-header", + FILE_HEADER, +] + + +@dataclass(frozen=True) +class Route: + class_name: str + method_name: str + http_method: str + path: str + + +def _resource_modules() -> list[ModuleType]: + return [ + importlib.import_module(info.name) + for info in pkgutil.walk_packages(discolike.resources.__path__, prefix=f"{discolike.resources.__name__}.") + ] + + +def collect_routes() -> list[Route]: + routes: dict[tuple[str, str], Route] = {} + for module in _resource_modules(): + for class_name, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module.__name__ or class_name.startswith(ASYNC_CLASS_PREFIX): + continue + for method_name, member in vars(cls).items(): + if not inspect.isfunction(member): + continue + route = get_discolike_route(member) + if route is None: + continue + http_method, path, openapi = route + if not openapi: + continue + routes.setdefault((http_method, path), Route(class_name, method_name, http_method, path)) + return list(routes.values()) + + +def params_model_name(*, class_name: str, method_name: str) -> str: + prefix = "" if class_name in CLIENT_LEVEL_RESOURCES else class_name.removesuffix(RESOURCE_SUFFIX) + return prefix + "".join(part.capitalize() for part in method_name.split("_")) + PARAMS_SUFFIX + + +def _synthesize_params_schema(operation: dict[str, Any]) -> dict[str, Any] | None: + parameters = [ + parameter + for parameter in operation.get("parameters", []) + if parameter["in"] == QUERY_LOCATION and not parameter.get("deprecated", False) + ] + if not parameters: + return None + schema: dict[str, Any] = { + "type": "object", + "properties": {parameter["name"]: parameter["schema"] for parameter in parameters}, + } + required = [parameter["name"] for parameter in parameters if parameter.get("required", False)] + if required: + schema["required"] = required + return schema + + +def _body_ref_name(operation: dict[str, Any]) -> str | None: + schema = operation.get("requestBody", {}).get("content", {}).get(JSON_MEDIA_TYPE, {}).get("schema", {}) + ref = schema.get("$ref") + return None if ref is None else ref.rsplit("/", 1)[1] + + +def request_schemas(*, spec: dict[str, Any], routes: list[Route]) -> dict[str, dict[str, Any]]: + schemas = spec["components"]["schemas"] + requested: dict[str, dict[str, Any]] = {} + for route in routes: + operation = spec["paths"].get(route.path, {}).get(route.http_method.lower()) + if operation is None: + raise SystemExit(f"{route.http_method} {route.path} is not in the spec; run scripts/check_contract.py") + body_name = _body_ref_name(operation) + if body_name is not None: + requested[body_name] = copy.deepcopy(schemas[body_name]) + continue + synthesized = _synthesize_params_schema(operation) + if synthesized is not None: + requested[params_model_name(class_name=route.class_name, method_name=route.method_name)] = synthesized + return requested + + +def _collect_refs(node: Any, refs: set[str]) -> None: # noqa: ANN401 -- walks arbitrary JSON + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + refs.add(ref.rsplit("/", 1)[1]) + for value in node.values(): + _collect_refs(value, refs) + elif isinstance(node, list): + for item in node: + _collect_refs(item, refs) + + +def prune(*, spec: dict[str, Any], requested: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + schemas = spec["components"]["schemas"] + kept = dict(requested) + pending: set[str] = set() + _collect_refs(kept, pending) + while pending: + name = pending.pop() + if name in kept: + continue + kept[name] = copy.deepcopy(schemas[name]) + _collect_refs(kept[name], pending) + return kept + + +def _normalize_property(schema: dict[str, Any], *, required: bool) -> dict[str, Any]: + variants = schema.get("anyOf") + if not required and variants is not None and len(variants) == 2 and NULL_SCHEMA in variants: + inner = next(variant for variant in variants if variant != NULL_SCHEMA) + outer = {key: value for key, value in schema.items() if key != "anyOf"} + schema = {**inner, **outer, "nullable": True} + items = schema.get("items") + if isinstance(items, dict): + schema = {**schema, "items": {key: value for key, value in items.items() if key in ITEM_KEYS_KEPT}} + return schema + + +def normalize_schema(schema: dict[str, Any]) -> dict[str, Any]: + required = set(schema.get("required", [])) + properties = { + name: _normalize_property(prop, required=name in required) + for name, prop in schema.get("properties", {}).items() + if not prop.get("deprecated", False) + } + return {key: value for key, value in schema.items() if key != "additionalProperties"} | {"properties": properties} + + +def build_codegen_spec(*, spec: dict[str, Any], routes: list[Route]) -> dict[str, Any]: + kept = prune(spec=spec, requested=request_schemas(spec=spec, routes=routes)) + return { + "openapi": "3.1.0", + "info": {"title": "discolike request models", "version": "0"}, + "paths": {}, + "components": {"schemas": {name: normalize_schema(schema) for name, schema in kept.items()}}, + } + + +def generate(*, spec: dict[str, Any], output: pathlib.Path) -> None: + codegen_spec = build_codegen_spec(spec=spec, routes=collect_routes()) + with tempfile.TemporaryDirectory() as tmp: + spec_path = pathlib.Path(tmp) / "requests-spec.json" + spec_path.write_text(json.dumps(codegen_spec)) + subprocess.run( # noqa: S603 -- fixed argv, no shell + [ + sys.executable, + "-m", + "datamodel_code_generator", + "--input", + str(spec_path), + "--output", + str(output), + *CODEGEN_ARGS, + ], + check=True, + ) + ruff = [sys.executable, "-m", "ruff"] + subprocess.run([*ruff, "check", "--fix", "--quiet", "--config", str(RUFF_CONFIG), str(output)], check=True) # noqa: S603 + subprocess.run([*ruff, "format", "--quiet", "--config", str(RUFF_CONFIG), str(output)], check=True) # noqa: S603 + + +def compare(*, committed: str, fresh: str) -> int: + relative = OUTPUT_PATH.relative_to(REPO_ROOT) + if committed == fresh: + print(f"{relative} is up to date") + return 0 + sys.stdout.writelines( + difflib.unified_diff( + committed.splitlines(keepends=True), + fresh.splitlines(keepends=True), + fromfile=f"{relative} (committed)", + tofile=f"{relative} (generated)", + ) + ) + print(f"{relative} is stale; run: uv run python scripts/gen_requests.py") + return 1 + + +def check(*, spec: dict[str, Any]) -> int: + with tempfile.TemporaryDirectory() as tmp: + candidate = pathlib.Path(tmp) / OUTPUT_PATH.name + generate(spec=spec, output=candidate) + fresh = candidate.read_text() + return compare(committed=OUTPUT_PATH.read_text(), fresh=fresh) + + +def load_spec(*, spec_path: str | None, spec_url: str) -> dict[str, Any]: + if spec_path is not None: + return json.loads(pathlib.Path(spec_path).read_text()) + response = httpx2.get(spec_url, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + return response.json() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate SDK request models from the platform OpenAPI spec.") + parser.add_argument("--spec", default=None, help="Path to a local OpenAPI spec JSON file (offline mode).") + parser.add_argument("--spec-url", default=SPEC_URL, help="OpenAPI spec URL (defaults to the production spec).") + parser.add_argument("--check", action="store_true", help="Regenerate to a temp file and fail if it differs.") + args = parser.parse_args() + spec = load_spec(spec_path=args.spec, spec_url=args.spec_url) + if args.check: + return check(spec=spec) + generate(spec=spec, output=OUTPUT_PATH) + print(f"wrote {OUTPUT_PATH.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 5923b206d3662693096d54f9a3567f1b06ba4f76 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:41:06 -0700 Subject: [PATCH 04/28] fix: make prune walk refs in a deterministic order Popping from a set left the transitive schema order dependent on PYTHONHASHSEED, so the generated class order could differ between runs and --check would report drift against a file nobody had touched. Only one schema is pulled transitively today, so this was latent rather than live, but it arms itself the moment a request component gains two sibling refs. --- packages/discolike/tests/test_gen_requests.py | 26 +++++++++++++++++++ scripts/gen_requests.py | 5 +++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 4596d25..5211aa1 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -80,6 +80,25 @@ } +SIBLING_REF_SPEC: dict[str, Any] = { + "components": { + "schemas": { + "Root": { + "type": "object", + "properties": { + "zebra": {"$ref": "#/components/schemas/Zebra"}, + "alpha": {"$ref": "#/components/schemas/Alpha"}, + "middle": {"$ref": "#/components/schemas/Middle"}, + }, + }, + "Zebra": {"type": "object", "properties": {"z": {"type": "string"}}}, + "Alpha": {"type": "object", "properties": {"a": {"type": "string"}}}, + "Middle": {"type": "object", "properties": {"m": {"type": "string"}}}, + } + } +} + + @pytest.fixture(scope="module") def gen(): spec = importlib.util.spec_from_file_location("gen_requests", SCRIPT_PATH) @@ -154,6 +173,13 @@ def test_prune_keeps_transitive_refs_and_drops_unrelated_schemas(gen, routes): assert set(gen.prune(spec=FAKE_SPEC, requested=requested)) == {"FindEmailBatchRequest", "FindEmailRequest"} +def test_prune_orders_sibling_refs_deterministically(gen): + requested = {"Root": SIBLING_REF_SPEC["components"]["schemas"]["Root"]} + kept = list(gen.prune(spec=SIBLING_REF_SPEC, requested=requested)) + assert kept == ["Root", "Alpha", "Middle", "Zebra"] + assert kept == list(gen.prune(spec=SIBLING_REF_SPEC, requested=requested)) + + def test_normalize_schema_inlines_nullable_and_drops_deprecated_and_additional_properties(gen): normalized = gen.normalize_schema(FAKE_SPEC["components"]["schemas"]["FindEmailRequest"]) assert "additionalProperties" not in normalized diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 625031b..976dd0d 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -158,7 +158,10 @@ def prune(*, spec: dict[str, Any], requested: dict[str, dict[str, Any]]) -> dict pending: set[str] = set() _collect_refs(kept, pending) while pending: - name = pending.pop() + # min() rather than pop(): set order varies with PYTHONHASHSEED, which would reorder + # the generated classes between runs and make --check report phantom drift. + name = min(pending) + pending.remove(name) if name in kept: continue kept[name] = copy.deepcopy(schemas[name]) From 67d80b46f4a610b629db7ebef7bd2d186e594a25 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:45:44 -0700 Subject: [PATCH 05/28] feat(sdk): commit generated request models and discolike.requests Generated from the dev spec: prod still exposes the discogen, validate, contacts-generate and provider bodies under FastAPI's inline Body_* names, which dev has already renamed. The release PR to main stays blocked by gen_requests.py --check until that deploys, the same gate check_contract.py imposes today. --- .../src/discolike/_generated/__init__.py | 0 .../src/discolike/_generated/requests.py | 2964 +++++++++++++++++ packages/discolike/src/discolike/requests.py | 75 + .../discolike/tests/test_requests_module.py | 40 + 4 files changed, 3079 insertions(+) create mode 100644 packages/discolike/src/discolike/_generated/__init__.py create mode 100644 packages/discolike/src/discolike/_generated/requests.py create mode 100644 packages/discolike/src/discolike/requests.py create mode 100644 packages/discolike/tests/test_requests_module.py diff --git a/packages/discolike/src/discolike/_generated/__init__.py b/packages/discolike/src/discolike/_generated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py new file mode 100644 index 0000000..107d326 --- /dev/null +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -0,0 +1,2964 @@ +# Generated by scripts/gen_requests.py from the platform OpenAPI spec. Do not edit by hand. + +from __future__ import annotations + +from typing import Annotated +from typing import Any +from typing import Literal + +from pydantic import Field + +from discolike._models import DiscolikeRequest + + +class CompaniesDataParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + + +class CompaniesScoreParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + + +class CompaniesGrowthParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + + +class CompaniesExtractParams(DiscolikeRequest): + url: Annotated[ + str | None, + Field(description="URL of the web page to extract content from.", title="Url"), + ] = None + domain: Annotated[ + str | None, + Field( + description="Bare domain to extract — alias for url=https://{domain}; hits the cached page when available.", + title="Domain", + ), + ] = None + + +class CompaniesRedirectsParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + match: Annotated[Literal["source", "linked"] | None, Field(title="Match")] = "source" + + +class CompaniesVendorsParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + match: Annotated[Literal["client", "vendor"] | None, Field(title="Match")] = "client" + + +class CompaniesSubsidiariesParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + match: Annotated[ + Literal["parent", "child", "source", "linked", "recursive"] | None, + Field(title="Match"), + ] = "parent" + + +class CompaniesPublicLinksParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + source: Annotated[Literal["email", "social", "phone"], Field(title="Source")] + + +class ContactsSearchParams(DiscolikeRequest): + filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Filter Industry", + ), + ] = None + negate_filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude contacts at companies in specified industries. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Filter Industry", + ), + ] = None + filter_country: Annotated[ + list[str] | None, + Field( + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Filter Country", + ), + ] = None + negate_filter_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified countries. Accepts same codes and region aliases as filter_country.", + title="Negate Filter Country", + ), + ] = None + filter_state: Annotated[ + list[str] | None, + Field(description="Filter by company state/region.", title="Filter State"), + ] = None + negate_filter_state: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified states.", + title="Negate Filter State", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '51,200'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Filter by seniority bucket: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Seniority", + ), + ] = None + negate_seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Exclude contacts with these seniority levels: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Negate Seniority", + ), + ] = None + department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Filter by department. Valid values: Operations, Executive, Technology, Sales - Marketing, Finance, Legal, Human Resources, Medical - Science, Customer Service, Research & Development, Administration, Public Relations, Investor Relations, Pro Services, Other.", + title="Department", + ), + ] = None + negate_department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Exclude contacts in specified departments. Same valid values as `department`.", + title="Negate Department", + ), + ] = None + skills: Annotated[ + list[str] | None, + Field( + description="Filter by skills. Multiple skills can be provided.", + title="Skills", + ), + ] = None + name: Annotated[ + str | None, + Field( + description="Filter by contact name (partial match supported).", + title="Name", + ), + ] = None + title: Annotated[ + list[str] | None, + Field( + description="Filter by job title. Each item is a separate match term. Supports quoted phrases and + prefix for required terms. C-suite acronyms are auto-expanded to their spelled-out forms and vice versa (e.g. 'CEO' also matches 'Chief Executive Officer'); wrap a term in quotes to match it literally without expansion.", + title="Title", + ), + ] = None + negate_title: Annotated[ + list[str] | None, + Field( + description="Exclude contacts with specified job titles. C-suite acronyms are expanded the same way as in 'title'.", + title="Negate Title", + ), + ] = None + summary: Annotated[ + str | None, + Field( + description="Filter by profile summary text (semantic search).", + title="Summary", + ), + ] = None + negate_summary: Annotated[ + str | None, + Field( + description="Exclude contacts matching this summary description.", + title="Negate Summary", + ), + ] = None + person_country: Annotated[ + list[str] | None, + Field( + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Person Country", + ), + ] = None + negate_person_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts in specified countries. Accepts same codes and region aliases as person_country.", + title="Negate Person Country", + ), + ] = None + person_state: Annotated[ + list[str] | None, + Field(description="Filter by contact's state/region.", title="Person State"), + ] = None + has_email: Annotated[ + bool | None, + Field(description="Only include contacts with email addresses.", title="Has Email"), + ] = False + email_validated: Annotated[ + bool | None, + Field( + description="Only include contacts with validated email addresses.", + title="Email Validated", + ), + ] = False + has_phone: Annotated[ + bool | None, + Field(description="Only include contacts with phone numbers.", title="Has Phone"), + ] = False + has_mobile: Annotated[ + bool | None, + Field( + description="Only include contacts with mobile phone numbers.", + title="Has Mobile", + ), + ] = False + has_linkedin: Annotated[ + bool | None, + Field( + description="Only include contacts with LinkedIn profiles.", + title="Has Linkedin", + ), + ] = False + min_connections: Annotated[ + int | None, + Field( + description="Minimum LinkedIn connections required.", + ge=0, + title="Min Connections", + ), + ] = None + jobstart_date: Annotated[ + str | None, + Field( + description="Filter by job start date: minimum date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD). Matches contacts who started their current role in this window; contacts without a known start date are excluded.", + title="Jobstart Date", + ), + ] = None + persona_id: Annotated[ + list[int] | None, + Field(description="Filter by specific persona IDs.", title="Persona Id"), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal contact profile for semantic matching.", + max_length=4000, + title="Icp Text", + ), + ] = None + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured contact filters (seniority, department, industry, country, employee/revenue range, etc.), cleans the semantic description, and applies them before running the contact search. User-provided filters take precedence over wizard-extracted ones.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + domain: Annotated[ + list[str] | None, + Field(description="Filter contacts at specific company domains.", title="Domain"), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include only contacts from companies in these saved queries.", + title="Inclusion Query Id", + ), + ] = None + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude contacts from companies in these saved queries.", + title="Exclusion Query Id", + ), + ] = None + max_records: Annotated[ + int | None, + Field( + description="Maximum number of contacts to return (20-10000).", + ge=20, + le=10000, + title="Max Records", + ), + ] = 100 + max_companies: Annotated[ + int | None, + Field( + description="Maximum number of enriched companies to return. Cannot be combined with `max_records`; when set, the internal total contact cap is derived from `max_companies * results_by_company`, capped at 10000.", + ge=1, + le=10000, + title="Max Companies", + ), + ] = None + offset: Annotated[ + int | None, + Field( + description="Number of results to skip for pagination.", + ge=0, + le=10000, + title="Offset", + ), + ] = 0 + results_by_company: Annotated[ + int | None, + Field( + description="Maximum contacts per company domain. Default 5 — i.e. without this parameter the endpoint returns up to 5 contacts from any one company so results spread across more distinct companies. Set `results_by_company=0` to remove the cap (return every matching contact per company, up to `max_records` total — or the global ceiling of 10000 when paired with `max_companies`). When the value is non-zero, `offset` is forced to 0 (pagination is disabled).", + ge=0, + le=100, + title="Results By Company", + ), + ] = 5 + include_search_contacts: Annotated[ + bool | None, + Field( + description="Include contacts from the search index (broader coverage).", + title="Include Search Contacts", + ), + ] = False + consensus: Annotated[ + int | None, + Field( + description="Number of query vectors to combine for consensus search.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + + +class ContactsCountParams(DiscolikeRequest): + filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Filter Industry", + ), + ] = None + negate_filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude contacts at companies in specified industries. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Filter Industry", + ), + ] = None + filter_country: Annotated[ + list[str] | None, + Field( + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Filter Country", + ), + ] = None + negate_filter_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified countries. Accepts same codes and region aliases as filter_country.", + title="Negate Filter Country", + ), + ] = None + filter_state: Annotated[ + list[str] | None, + Field(description="Filter by company state/region.", title="Filter State"), + ] = None + negate_filter_state: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified states.", + title="Negate Filter State", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '51,200'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Filter by seniority bucket: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Seniority", + ), + ] = None + negate_seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Exclude contacts with these seniority levels: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Negate Seniority", + ), + ] = None + department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Filter by department. Valid values: Operations, Executive, Technology, Sales - Marketing, Finance, Legal, Human Resources, Medical - Science, Customer Service, Research & Development, Administration, Public Relations, Investor Relations, Pro Services, Other.", + title="Department", + ), + ] = None + negate_department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Exclude contacts in specified departments. Same valid values as `department`.", + title="Negate Department", + ), + ] = None + skills: Annotated[ + list[str] | None, + Field( + description="Filter by skills. Multiple skills can be provided.", + title="Skills", + ), + ] = None + name: Annotated[ + str | None, + Field( + description="Filter by contact name (partial match supported).", + title="Name", + ), + ] = None + title: Annotated[ + list[str] | None, + Field( + description="Filter by job title. Each item is a separate match term. Supports quoted phrases and + prefix for required terms. C-suite acronyms are auto-expanded to their spelled-out forms and vice versa (e.g. 'CEO' also matches 'Chief Executive Officer'); wrap a term in quotes to match it literally without expansion.", + title="Title", + ), + ] = None + negate_title: Annotated[ + list[str] | None, + Field( + description="Exclude contacts with specified job titles. C-suite acronyms are expanded the same way as in 'title'.", + title="Negate Title", + ), + ] = None + summary: Annotated[ + str | None, + Field( + description="Filter by profile summary text (semantic search).", + title="Summary", + ), + ] = None + negate_summary: Annotated[ + str | None, + Field( + description="Exclude contacts matching this summary description.", + title="Negate Summary", + ), + ] = None + person_country: Annotated[ + list[str] | None, + Field( + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Person Country", + ), + ] = None + negate_person_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts in specified countries. Accepts same codes and region aliases as person_country.", + title="Negate Person Country", + ), + ] = None + person_state: Annotated[ + list[str] | None, + Field(description="Filter by contact's state/region.", title="Person State"), + ] = None + has_email: Annotated[ + bool | None, + Field(description="Only include contacts with email addresses.", title="Has Email"), + ] = False + email_validated: Annotated[ + bool | None, + Field( + description="Only include contacts with validated email addresses.", + title="Email Validated", + ), + ] = False + has_phone: Annotated[ + bool | None, + Field(description="Only include contacts with phone numbers.", title="Has Phone"), + ] = False + has_mobile: Annotated[ + bool | None, + Field( + description="Only include contacts with mobile phone numbers.", + title="Has Mobile", + ), + ] = False + has_linkedin: Annotated[ + bool | None, + Field( + description="Only include contacts with LinkedIn profiles.", + title="Has Linkedin", + ), + ] = False + min_connections: Annotated[ + int | None, + Field( + description="Minimum LinkedIn connections required.", + ge=0, + title="Min Connections", + ), + ] = None + jobstart_date: Annotated[ + str | None, + Field( + description="Filter by job start date: minimum date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD). Matches contacts who started their current role in this window; contacts without a known start date are excluded.", + title="Jobstart Date", + ), + ] = None + persona_id: Annotated[ + list[int] | None, + Field(description="Filter by specific persona IDs.", title="Persona Id"), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal contact profile for semantic matching.", + max_length=4000, + title="Icp Text", + ), + ] = None + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured contact filters (seniority, department, industry, country, employee/revenue range, etc.), cleans the semantic description, and applies them before running the contact search. User-provided filters take precedence over wizard-extracted ones.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + domain: Annotated[ + list[str] | None, + Field(description="Filter contacts at specific company domains.", title="Domain"), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include only contacts from companies in these saved queries.", + title="Inclusion Query Id", + ), + ] = None + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude contacts from companies in these saved queries.", + title="Exclusion Query Id", + ), + ] = None + max_records: Annotated[ + int | None, + Field( + description="Maximum number of contacts to return (20-10000).", + ge=20, + le=10000, + title="Max Records", + ), + ] = 100 + max_companies: Annotated[ + int | None, + Field( + description="Maximum number of enriched companies to return. Cannot be combined with `max_records`; when set, the internal total contact cap is derived from `max_companies * results_by_company`, capped at 10000.", + ge=1, + le=10000, + title="Max Companies", + ), + ] = None + offset: Annotated[ + int | None, + Field( + description="Number of results to skip for pagination.", + ge=0, + le=10000, + title="Offset", + ), + ] = 0 + results_by_company: Annotated[ + int | None, + Field( + description="Maximum contacts per company domain. Default 5 — i.e. without this parameter the endpoint returns up to 5 contacts from any one company so results spread across more distinct companies. Set `results_by_company=0` to remove the cap (return every matching contact per company, up to `max_records` total — or the global ceiling of 10000 when paired with `max_companies`). When the value is non-zero, `offset` is forced to 0 (pagination is disabled).", + ge=0, + le=100, + title="Results By Company", + ), + ] = 5 + include_search_contacts: Annotated[ + bool | None, + Field( + description="Include contacts from the search index (broader coverage).", + title="Include Search Contacts", + ), + ] = False + consensus: Annotated[ + int | None, + Field( + description="Number of query vectors to combine for consensus search.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + + +class ContactsLookupParams(DiscolikeRequest): + persona_id: Annotated[int | None, Field(description="The persona ID to look up.", title="Persona Id")] = None + linkedin: Annotated[ + str | None, + Field(description="LinkedIn URL or username to look up.", title="Linkedin"), + ] = None + email: Annotated[ + str | None, + Field(description="Email address to look up (exact match).", title="Email"), + ] = None + + +class ContactsMatchParams(DiscolikeRequest): + name: Annotated[str, Field(description="Person name to search for.", title="Name")] + company_name: Annotated[ + str | None, + Field(description="Company name to narrow the search.", title="Company Name"), + ] = None + domain: Annotated[str | None, Field(description="Domain to filter results.", title="Domain")] = None + person_country: Annotated[ + str | None, + Field( + description="Person's country code (ISO-3166-1 alpha-2).", + title="Person Country", + ), + ] = None + limit: Annotated[ + int | None, + Field(description="Maximum results to return (1-20).", ge=1, le=20, title="Limit"), + ] = 10 + + +class ContactFilters(DiscolikeRequest): + filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Filter Industry", + ), + ] = None + negate_filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude contacts at companies in specified industries. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Filter Industry", + ), + ] = None + filter_country: Annotated[ + list[str] | None, + Field( + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Filter Country", + ), + ] = None + negate_filter_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified countries. Accepts same codes and region aliases as filter_country.", + title="Negate Filter Country", + ), + ] = None + filter_state: Annotated[ + list[str] | None, + Field(description="Filter by company state/region.", title="Filter State"), + ] = None + negate_filter_state: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified states.", + title="Negate Filter State", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '51,200'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Filter by seniority bucket: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Seniority", + ), + ] = None + negate_seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Exclude contacts with these seniority levels: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Negate Seniority", + ), + ] = None + department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Filter by department. Valid values: Operations, Executive, Technology, Sales - Marketing, Finance, Legal, Human Resources, Medical - Science, Customer Service, Research & Development, Administration, Public Relations, Investor Relations, Pro Services, Other.", + title="Department", + ), + ] = None + negate_department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Exclude contacts in specified departments. Same valid values as `department`.", + title="Negate Department", + ), + ] = None + skills: Annotated[ + list[str] | None, + Field( + description="Filter by skills. Multiple skills can be provided.", + title="Skills", + ), + ] = None + name: Annotated[ + str | None, + Field( + description="Filter by contact name (partial match supported).", + title="Name", + ), + ] = None + title: Annotated[ + list[str] | None, + Field( + description="Filter by job title. Each item is a separate match term. Supports quoted phrases and + prefix for required terms. C-suite acronyms are auto-expanded to their spelled-out forms and vice versa (e.g. 'CEO' also matches 'Chief Executive Officer'); wrap a term in quotes to match it literally without expansion.", + title="Title", + ), + ] = None + negate_title: Annotated[ + list[str] | None, + Field( + description="Exclude contacts with specified job titles. C-suite acronyms are expanded the same way as in 'title'.", + title="Negate Title", + ), + ] = None + summary: Annotated[ + str | None, + Field( + description="Filter by profile summary text (semantic search).", + title="Summary", + ), + ] = None + negate_summary: Annotated[ + str | None, + Field( + description="Exclude contacts matching this summary description.", + title="Negate Summary", + ), + ] = None + person_country: Annotated[ + list[str] | None, + Field( + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Person Country", + ), + ] = None + negate_person_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts in specified countries. Accepts same codes and region aliases as person_country.", + title="Negate Person Country", + ), + ] = None + person_state: Annotated[ + list[str] | None, + Field(description="Filter by contact's state/region.", title="Person State"), + ] = None + has_email: Annotated[ + bool | None, + Field(description="Only include contacts with email addresses.", title="Has Email"), + ] = False + email_validated: Annotated[ + bool | None, + Field( + description="Only include contacts with validated email addresses.", + title="Email Validated", + ), + ] = False + has_phone: Annotated[ + bool | None, + Field(description="Only include contacts with phone numbers.", title="Has Phone"), + ] = False + has_mobile: Annotated[ + bool | None, + Field( + description="Only include contacts with mobile phone numbers.", + title="Has Mobile", + ), + ] = False + has_linkedin: Annotated[ + bool | None, + Field( + description="Only include contacts with LinkedIn profiles.", + title="Has Linkedin", + ), + ] = False + min_connections: Annotated[ + int | None, + Field( + description="Minimum LinkedIn connections required.", + ge=0, + title="Min Connections", + ), + ] = None + jobstart_date: Annotated[ + str | None, + Field( + description="Filter by job start date: minimum date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD). Matches contacts who started their current role in this window; contacts without a known start date are excluded.", + title="Jobstart Date", + ), + ] = None + persona_id: Annotated[ + list[int] | None, + Field(description="Filter by specific persona IDs.", title="Persona Id"), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal contact profile for semantic matching.", + max_length=4000, + title="Icp Text", + ), + ] = None + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured contact filters (seniority, department, industry, country, employee/revenue range, etc.), cleans the semantic description, and applies them before running the contact search. User-provided filters take precedence over wizard-extracted ones.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + domain: Annotated[ + list[str] | None, + Field(description="Filter contacts at specific company domains.", title="Domain"), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include only contacts from companies in these saved queries.", + title="Inclusion Query Id", + ), + ] = None + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude contacts from companies in these saved queries.", + title="Exclusion Query Id", + ), + ] = None + max_records: Annotated[ + int | None, + Field( + description="Maximum number of contacts to return (20-10000).", + ge=20, + le=10000, + title="Max Records", + ), + ] = 100 + max_companies: Annotated[ + int | None, + Field( + description="Maximum number of enriched companies to return. Cannot be combined with `max_records`; when set, the internal total contact cap is derived from `max_companies * results_by_company`, capped at 10000.", + ge=1, + le=10000, + title="Max Companies", + ), + ] = None + offset: Annotated[ + int | None, + Field( + description="Number of results to skip for pagination.", + ge=0, + le=10000, + title="Offset", + ), + ] = 0 + results_by_company: Annotated[ + int | None, + Field( + description="Maximum contacts per company domain. Default 5 — i.e. without this parameter the endpoint returns up to 5 contacts from any one company so results spread across more distinct companies. Set `results_by_company=0` to remove the cap (return every matching contact per company, up to `max_records` total — or the global ceiling of 10000 when paired with `max_companies`). When the value is non-zero, `offset` is forced to 0 (pagination is disabled).", + ge=0, + le=100, + title="Results By Company", + ), + ] = 5 + include_search_contacts: Annotated[ + bool | None, + Field( + description="Include contacts from the search index (broader coverage).", + title="Include Search Contacts", + ), + ] = False + consensus: Annotated[ + int | None, + Field( + description="Number of query vectors to combine for consensus search.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + + +class ContactGenerateRequest(DiscolikeRequest): + icp_text: Annotated[ + str, + Field( + description="ICP / persona description of the contacts to find.", + title="Icp Text", + ), + ] + domains: Annotated[ + list[str], + Field( + description="Company domains to search at (1-10000).", + max_length=10000, + min_length=1, + title="Domains", + ), + ] + context_mode: Annotated[Literal["website", "profile", "domain"] | None, Field(title="Context Mode")] = "website" + integration_id: Annotated[str | None, Field(title="Integration Id")] = None + search_provider_id: Annotated[str | None, Field(title="Search Provider Id")] = None + search_context_size: Annotated[Literal["low", "medium", "high"] | None, Field(title="Search Context Size")] = "low" + max_contacts_per_domain: Annotated[int | None, Field(title="Max Contacts Per Domain")] = 10 + max_company_records: Annotated[int | None, Field(title="Max Company Records")] = None + full_domains: Annotated[list[str] | None, Field(title="Full Domains")] = None + partial_domains: Annotated[list[str] | None, Field(title="Partial Domains")] = None + initial_contact_counts: Annotated[dict[str, int] | None, Field(title="Initial Contact Counts")] = None + + +class DiscoGenProcessRequest(DiscolikeRequest): + query: Annotated[ + str, + Field(description="Prompt to run against each record", min_length=1, title="Query"), + ] + integration_id: Annotated[ + str | None, + Field( + description="LLM provider integration UUID (omit for org default)", + title="Integration Id", + ), + ] = None + web_search: Annotated[bool | None, Field(title="Web Search")] = False + include_x_search: Annotated[bool | None, Field(title="Include X Search")] = False + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider integration UUID (omit for org default)", + title="Search Provider Id", + ), + ] = None + search_context_size: Annotated[Literal["low", "medium", "high"] | None, Field(title="Search Context Size")] = "low" + domains: Annotated[ + list[str], + Field( + description="Domains to process", + max_length=10000, + min_length=1, + title="Domains", + ), + ] + context_mode: Annotated[Literal["website", "profile", "domain"] | None, Field(title="Context Mode")] = "website" + previous_discogen_data: Annotated[dict[str, Any] | None, Field(title="Previous Discogen Data")] = None + + +class DiscoGenPersonaProcessRequest(DiscolikeRequest): + query: Annotated[ + str, + Field(description="Prompt to run against each record", min_length=1, title="Query"), + ] + integration_id: Annotated[ + str | None, + Field( + description="LLM provider integration UUID (omit for org default)", + title="Integration Id", + ), + ] = None + web_search: Annotated[bool | None, Field(title="Web Search")] = False + include_x_search: Annotated[bool | None, Field(title="Include X Search")] = False + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider integration UUID (omit for org default)", + title="Search Provider Id", + ), + ] = None + search_context_size: Annotated[Literal["low", "medium", "high"] | None, Field(title="Search Context Size")] = "low" + persona_ids: Annotated[ + list[int], + Field( + description="Contact record IDs to process", + max_length=10000, + min_length=1, + title="Persona Ids", + ), + ] + context_mode: Annotated[ + Literal["name_only", "profile", "profile_summary", "company", "full"] | None, + Field(title="Context Mode"), + ] = "profile" + previous_discogen_data: Annotated[dict[str, Any] | None, Field(title="Previous Discogen Data")] = None + + +class ValidateIcpRequest(DiscolikeRequest): + icp_text: Annotated[ + str, + Field( + description="Ideal customer profile description to validate domains against", + title="Icp Text", + ), + ] + domains: Annotated[ + list[str], + Field( + description="Domains to validate", + max_length=10000, + min_length=1, + title="Domains", + ), + ] + context_mode: Annotated[Literal["website", "profile", "domain"] | None, Field(title="Context Mode")] = "website" + integration_id: Annotated[ + str | None, + Field( + description="LLM provider integration UUID (omit for org default)", + title="Integration Id", + ), + ] = None + web_search: Annotated[bool | None, Field(title="Web Search")] = False + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider integration UUID (omit for org default)", + title="Search Provider Id", + ), + ] = None + + +class DiscoverParams(DiscolikeRequest): + phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to search for in site content. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Phrase Match", + ), + ] = None + negate_phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to exclude from results. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Negate Phrase Match", + ), + ] = None + subdomain: Annotated[ + list[str] | None, + Field( + description="Limit results to specified subdomains. Up to 20, each at least 3 characters.", + max_length=20, + title="Subdomain", + ), + ] = None + negate_subdomain: Annotated[ + list[str] | None, + Field( + description="Exclude specified subdomains from results. Up to 20, each at least 3 characters.", + max_length=20, + title="Negate Subdomain", + ), + ] = None + tech_stack: Annotated[ + list[str] | None, + Field( + description="Filter to companies using specified vendor domains (up to 20).", + max_length=20, + title="Tech Stack", + ), + ] = None + negate_tech_stack: Annotated[ + list[str] | None, + Field( + description="Exclude companies using specified vendor domains (up to 20).", + max_length=20, + title="Negate Tech Stack", + ), + ] = None + category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Category", + ), + ] = None + negate_category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude specified industry categories. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Category", + ), + ] = None + min_digital_footprint: Annotated[ + int | None, + Field( + description="Minimum digital footprint score (0-800). Default 50.", + ge=0, + le=800, + title="Min Digital Footprint", + ), + ] = None + max_digital_footprint: Annotated[ + int | None, + Field( + description="Maximum digital footprint score (0-800). Default 800.", + ge=0, + le=800, + title="Max Digital Footprint", + ), + ] = None + state: Annotated[ + list[str] | None, + Field( + description="Filter by state codes (up to 100). Not supported with multiple countries.", + max_length=100, + title="State", + ), + ] = None + negate_state: Annotated[ + list[str] | None, + Field( + description="Exclude specified states from results (up to 100).", + max_length=100, + title="Negate State", + ), + ] = None + country: Annotated[ + list[str] | None, + Field( + description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Country", + ), + ] = None + negate_country: Annotated[ + list[str] | None, + Field( + description="Exclude specified countries from results. Accepts same codes and region aliases as country.", + title="Negate Country", + ), + ] = None + start_date: Annotated[ + str | None, + Field( + description="Minimum company start date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD).", + title="Start Date", + ), + ] = None + redirect: Annotated[ + bool | None, + Field( + description="Include domains that redirect to another domain.", + title="Redirect", + ), + ] = False + social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Social", + ), + ] = None + negate_social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Exclude companies with specified social profiles. Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Negate Social", + ), + ] = None + language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Language", + ), + ] = None + negate_language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Exclude specified languages from results. Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Negate Language", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '1,5000'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + revenue_range: Annotated[ + str | None, + Field( + description="Filter by company revenue. Format: 'min,max' in raw numbers (e.g., '1000000,10000000' for 1M-10M). Maps to buckets: <1M, 1-10M, 10-100M, 100M-1B, >1B. Unknown-revenue domains (N/A) are included when the range covers the <1M bucket.", + title="Revenue Range", + ), + ] = None + business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Business Model", + ), + ] = None + negate_business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Exclude domains with these business model labels. Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Negate Business Model", + ), + ] = None + exclude_leadgen: Annotated[ + bool | None, + Field( + description="Exclude suspected lead generation sites. Filters out profiles with score <= 25 that also lack phone, email, and social media presence.", + title="Exclude Leadgen", + ), + ] = True + domain: Annotated[ + list[str] | None, + Field( + description="Domain(s) for lookalike matching. Up to 10 domains allowed.", + max_length=10, + title="Domain", + ), + ] = None + exclude_domain: Annotated[ + list[str] | None, + Field( + description="Hard-exclude these exact domains from results without affecting lookalike matching. Accepts comma-separated or repeated params. Up to 100 domains.", + max_length=100, + title="Exclude Domain", + ), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal customer profile for semantic matching (3-4000 characters).", + max_length=4000, + min_length=3, + title="Icp Text", + ), + ] = None + retrieval: Annotated[ + bool | None, + Field( + description="Enable page data retrieval using Extract API.", + title="Retrieval", + ), + ] = False + enhanced: Annotated[ + bool | None, + Field( + description="Enable AI-powered result enhancement for improved relevance.", + title="Enhanced", + ), + ] = False + include_search_domains: Annotated[ + bool | None, + Field( + description="Include input domains in results (excluded by default).", + title="Include Search Domains", + ), + ] = False + max_records: Annotated[ + int | None, + Field( + description="Maximum records to return (5-10000). Default 100.", + ge=5, + le=10000, + title="Max Records", + ), + ] = 100 + consensus: Annotated[ + int | None, + Field( + description="Number of top results for consensus search vector (1-20). Higher values reduce specificity.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + min_similarity: Annotated[ + int | None, + Field( + description="Minimum similarity score to include (0-99).", + ge=0, + le=99, + title="Min Similarity", + ), + ] = 0 + variance: Annotated[ + Literal["LOW", "MID_LOW", "MEDIUM", "MID_HIGH", "HIGH", "UNRESTRICTED"] | None, + Field( + description="Result diversity control: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED.", + title="Variance", + ), + ] = "UNRESTRICTED" + offset: Annotated[ + int | None, + Field(description="Records to skip for pagination.", ge=0, title="Offset"), + ] = 0 + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude domains from saved queries.", + title="Exclusion Query Id", + ), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include domains from saved queries. Requires STARTER plan.", + title="Inclusion Query Id", + ), + ] = None + auto_icp_text: Annotated[ + bool | None, + Field( + description="Auto-generate ICP text from provided domain(s).", + title="Auto Icp Text", + ), + ] = False + auto_phrase_match: Annotated[ + bool | None, + Field( + description="Auto-generate phrase matches from ICP text.", + title="Auto Phrase Match", + ), + ] = False + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured filters, generates ICP text, suggests domains, and applies them before running discovery. Overrides auto_icp_text and auto_phrase_match when set.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + + +class CountParams(DiscolikeRequest): + phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to search for in site content. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Phrase Match", + ), + ] = None + negate_phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to exclude from results. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Negate Phrase Match", + ), + ] = None + subdomain: Annotated[ + list[str] | None, + Field( + description="Limit results to specified subdomains. Up to 20, each at least 3 characters.", + max_length=20, + title="Subdomain", + ), + ] = None + negate_subdomain: Annotated[ + list[str] | None, + Field( + description="Exclude specified subdomains from results. Up to 20, each at least 3 characters.", + max_length=20, + title="Negate Subdomain", + ), + ] = None + tech_stack: Annotated[ + list[str] | None, + Field( + description="Filter to companies using specified vendor domains (up to 20).", + max_length=20, + title="Tech Stack", + ), + ] = None + negate_tech_stack: Annotated[ + list[str] | None, + Field( + description="Exclude companies using specified vendor domains (up to 20).", + max_length=20, + title="Negate Tech Stack", + ), + ] = None + category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Category", + ), + ] = None + negate_category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude specified industry categories. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Category", + ), + ] = None + min_digital_footprint: Annotated[ + int | None, + Field( + description="Minimum digital footprint score (0-800). Default 50.", + ge=0, + le=800, + title="Min Digital Footprint", + ), + ] = None + max_digital_footprint: Annotated[ + int | None, + Field( + description="Maximum digital footprint score (0-800). Default 800.", + ge=0, + le=800, + title="Max Digital Footprint", + ), + ] = None + state: Annotated[ + list[str] | None, + Field( + description="Filter by state codes (up to 100). Not supported with multiple countries.", + max_length=100, + title="State", + ), + ] = None + negate_state: Annotated[ + list[str] | None, + Field( + description="Exclude specified states from results (up to 100).", + max_length=100, + title="Negate State", + ), + ] = None + country: Annotated[ + list[str] | None, + Field( + description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Country", + ), + ] = None + negate_country: Annotated[ + list[str] | None, + Field( + description="Exclude specified countries from results. Accepts same codes and region aliases as country.", + title="Negate Country", + ), + ] = None + start_date: Annotated[ + str | None, + Field( + description="Minimum company start date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD).", + title="Start Date", + ), + ] = None + redirect: Annotated[ + bool | None, + Field( + description="Include domains that redirect to another domain.", + title="Redirect", + ), + ] = False + social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Social", + ), + ] = None + negate_social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Exclude companies with specified social profiles. Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Negate Social", + ), + ] = None + language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Language", + ), + ] = None + negate_language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Exclude specified languages from results. Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Negate Language", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '1,5000'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + revenue_range: Annotated[ + str | None, + Field( + description="Filter by company revenue. Format: 'min,max' in raw numbers (e.g., '1000000,10000000' for 1M-10M). Maps to buckets: <1M, 1-10M, 10-100M, 100M-1B, >1B. Unknown-revenue domains (N/A) are included when the range covers the <1M bucket.", + title="Revenue Range", + ), + ] = None + business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Business Model", + ), + ] = None + negate_business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Exclude domains with these business model labels. Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Negate Business Model", + ), + ] = None + exclude_leadgen: Annotated[ + bool | None, + Field( + description="Exclude suspected lead generation sites. Filters out profiles with score <= 25 that also lack phone, email, and social media presence.", + title="Exclude Leadgen", + ), + ] = True + + +class FindEmailRequest(DiscolikeRequest): + first_name: Annotated[str, Field(description="First name of the person", title="First Name")] + last_name: Annotated[str, Field(description="Last name of the person", title="Last Name")] + domain: Annotated[str, Field(description="Target email domain", title="Domain")] + known_pattern: Annotated[ + str | None, + Field(description="Known email pattern for this domain", title="Known Pattern"), + ] = None + + +class FindEmailBatchRequest(DiscolikeRequest): + requests: Annotated[ + list[FindEmailRequest], + Field( + description="Find-email requests to process", + max_length=500, + min_length=1, + title="Requests", + ), + ] + + +class AppendParams(DiscolikeRequest): + domain_column: Annotated[ + str | None, + Field( + description="Column name containing domains (auto-detected if not provided).", + title="Domain Column", + ), + ] = "domain" + csv: Annotated[ + bool | None, + Field(description="Return results as CSV instead of JSON.", title="Csv"), + ] = False + dataset: Annotated[ + list[Literal["bizdata", "redirects", "domain_status", "growth", "vendors"]], + Field(description="Datasets to append.", min_length=1, title="Dataset"), + ] + query_id: Annotated[ + list[str] | None, + Field( + description="Append domains from these saved queries, unioned with any file-derived domains.", + title="Query Id", + ), + ] = None + + +class SegmentFileParams(DiscolikeRequest): + domain_column: Annotated[ + str | None, + Field( + description="Column name containing domains (auto-detected if not provided).", + title="Domain Column", + ), + ] = "domain" + max_segments: Annotated[ + int | None, + Field( + description="Maximum number of segments to create (2-100).", + ge=2, + le=100, + title="Max Segments", + ), + ] = None + query_id: Annotated[ + list[str] | None, + Field( + description="Include domains from these saved queries in the segmentation.", + title="Query Id", + ), + ] = None + + +class SegmentParams(DiscolikeRequest): + domains: Annotated[ + str | None, + Field( + description="Comma-separated list of domains to segment into topic clusters", + title="Domains", + ), + ] = "" + query_id: Annotated[ + list[str] | None, + Field( + description="Include domains from these saved queries in the segmentation.", + title="Query Id", + ), + ] = None + max_segments: Annotated[ + int | None, + Field( + description="Maximum number of segments to create (2-20)", + ge=2, + le=20, + title="Max Segments", + ), + ] = None + + +class MatchCompanyParams(DiscolikeRequest): + name: Annotated[str, Field(description="The company name to match", title="Name")] + phone: Annotated[ + str | None, + Field( + description="Phone number to augment the search (E.164 or local format)", + title="Phone", + ), + ] = None + city: Annotated[str | None, Field(description="City to augment the search", title="City")] = None + state: Annotated[str | None, Field(description="State code to augment the search", title="State")] = None + country: Annotated[ + str | None, + Field( + description="ISO-3166-1 alpha-2 country code to augment the search", + title="Country", + ), + ] = None + zip_code: Annotated[ + str | None, + Field(description="Zip code to augment the search", title="Zip Code"), + ] = None + strict: Annotated[ + bool | None, + Field(description="Enable strict matching (no filter relaxation)", title="Strict"), + ] = False + local_mode: Annotated[ + bool | None, + Field( + description="Preserve location filters during relaxation.", + title="Local Mode", + ), + ] = False + min_match_confidence: Annotated[ + int | None, + Field( + description="Minimum match_confidence (inclusive, 50-100) a match must have to be returned. Defaults to 50, the standard quality floor.", + ge=50, + le=100, + title="Min Match Confidence", + ), + ] = 50 + + +class MatchBulkParams(DiscolikeRequest): + name_column: Annotated[ + str, + Field(description="Column name containing company names.", title="Name Column"), + ] + phone_column: Annotated[ + str | None, + Field(description="Column name containing phone numbers.", title="Phone Column"), + ] = None + city_column: Annotated[ + str | None, + Field(description="Column name containing cities.", title="City Column"), + ] = None + state_column: Annotated[ + str | None, + Field(description="Column name containing states.", title="State Column"), + ] = None + country_column: Annotated[ + str | None, + Field(description="Column name containing country codes.", title="Country Column"), + ] = None + zip_code_column: Annotated[ + str | None, + Field(description="Column name containing zip codes.", title="Zip Code Column"), + ] = None + strict: Annotated[bool | None, Field(description="Enable strict matching.", title="Strict")] = False + local_mode: Annotated[bool | None, Field(description="Preserve location filters.", title="Local Mode")] = False + min_match_confidence: Annotated[ + int | None, + Field( + description="Minimum match_confidence (inclusive, 50-100) a match must have to be returned. Defaults to 50, the standard quality floor.", + ge=50, + le=100, + title="Min Match Confidence", + ), + ] = 50 + + +class LLMProviderCreateRequest(DiscolikeRequest): + integration_name: Annotated[ + str, + Field( + description="User-friendly name for this integration", + title="Integration Name", + ), + ] + provider: Annotated[ + str, + Field( + description="LLM provider name (openai, anthropic, custom)", + title="Provider", + ), + ] + api_key: Annotated[str, Field(description="API key for the provider", title="Api Key")] + model_name: Annotated[ + str, + Field(description="Default model name for this integration", title="Model Name"), + ] + base_url: Annotated[ + str | None, + Field( + description="Custom endpoint URL (required for 'custom' provider, null for cloud)", + title="Base Url", + ), + ] = None + + +class LLMProviderUpdateRequest(DiscolikeRequest): + integration_name: Annotated[ + str, + Field( + description="User-friendly name for this integration", + title="Integration Name", + ), + ] + provider: Annotated[ + str, + Field( + description="LLM provider name (openai, anthropic, custom)", + title="Provider", + ), + ] + api_key: Annotated[ + str | None, + Field( + description="API key for the provider (null to keep existing)", + title="Api Key", + ), + ] + model_name: Annotated[ + str, + Field(description="Default model name for this integration", title="Model Name"), + ] + base_url: Annotated[ + str | None, + Field( + description="Custom endpoint URL (required for 'custom' provider, null for cloud)", + title="Base Url", + ), + ] = None + + +class SearchProviderRequest(DiscolikeRequest): + integration_name: Annotated[str, Field(description="User-friendly name", title="Integration Name")] + provider: Annotated[ + str, + Field(description="LiteLLM provider key (tavily, serper, etc.)", title="Provider"), + ] + search_model: Annotated[ + str, + Field( + description="LiteLLM model key for cost lookup (tavily/search, etc.)", + title="Search Model", + ), + ] + api_key: Annotated[ + str | None, + Field(description="Plaintext API key (null to keep unchanged)", title="Api Key"), + ] = None + base_url: Annotated[ + str | None, + Field(description="Custom endpoint URL for LiteLLM Proxy", title="Base Url"), + ] = None + + +class QueriesListParams(DiscolikeRequest): + max_records: Annotated[int | None, Field(ge=1, le=1000, title="Max Records")] = 100 + offset: Annotated[int | None, Field(ge=0, title="Offset")] = 0 + action: Annotated[ + str | None, + Field( + description="Filter by action type (e.g. discover, exclusion)", + title="Action", + ), + ] = None + tags: Annotated[ + list[str] | None, + Field( + description="Filter by tags (matches any). Repeated param or comma-separated.", + title="Tags", + ), + ] = None + + +class CreateExclusionListRequest(DiscolikeRequest): + query_name: Annotated[ + str, + Field( + description="Name for the exclusion list", + max_length=255, + min_length=1, + title="Query Name", + ), + ] + domains: Annotated[ + list[str] | None, + Field(description="List of domains for the exclusion list", title="Domains"), + ] = None + persona_ids: Annotated[ + list[int] | None, + Field( + description="List of persona IDs for the exclusion list", + title="Persona Ids", + ), + ] = None + tags: Annotated[ + list[str] | None, + Field( + description="Tags to apply to the exclusion list (max 20 tags, each 2-50 chars, alphanumeric/hyphen/underscore)", + max_length=20, + title="Tags", + ), + ] = None + + +class SaveResultsRequest(DiscolikeRequest): + query_name: Annotated[ + str, + Field( + description="Name for the query", + max_length=255, + min_length=1, + title="Query Name", + ), + ] + action: Annotated[ + Literal["discover", "segment", "contacts", "append", "match"], + Field( + description="Underlying action type. One of: ['discover', 'segment', 'contacts', 'append', 'match']", + title="Action", + ), + ] + query_params: Annotated[ + dict[str, Any] | None, + Field( + description="Original query params for UI reconstruction", + title="Query Params", + ), + ] = None + data: Annotated[ + list[dict[str, Any]], + Field(description="Thin rows: domain + custom columns", min_length=1, title="Data"), + ] + domain_column: Annotated[ + str | None, + Field( + description="Column name containing domains", + max_length=128, + title="Domain Column", + ), + ] = "domain" + tags: Annotated[ + list[str] | None, + Field(description="Initial tags to apply", max_length=20, title="Tags"), + ] = None + + +class UpdateQueryRequest(DiscolikeRequest): + query_name: Annotated[ + str | None, + Field(description="New name for the query", max_length=255, title="Query Name"), + ] = None + tags: Annotated[ + list[str] | None, + Field(description="Complete list of tags to set", max_length=20, title="Tags"), + ] = None + + +class BulkContactMatchQueryItem(DiscolikeRequest): + name: Annotated[ + str | None, + Field( + description="Person name to search for. Required unless email is provided.", + title="Name", + ), + ] = None + email: Annotated[ + str | None, + Field( + description="Email address for exact lookup. When found, name/company matching is skipped for this row.", + title="Email", + ), + ] = None + company_name: Annotated[ + str | None, + Field(description="Company name to narrow the search.", title="Company Name"), + ] = None + domain: Annotated[str | None, Field(description="Domain to filter results.", title="Domain")] = None + person_country: Annotated[ + str | None, + Field( + description="Person's country code (ISO-3166-1 alpha-2).", + title="Person Country", + ), + ] = None + + +class BulkContactMatchRequest(DiscolikeRequest): + queries: Annotated[ + list[BulkContactMatchQueryItem], + Field( + description="List of contact match queries to process.", + max_length=10000, + min_length=1, + title="Queries", + ), + ] + enrich: Annotated[ + bool | None, + Field( + description="When true, hydrate full contact data from the vector database (costs credits).", + title="Enrich", + ), + ] = False + limit: Annotated[ + int | None, + Field(description="Maximum matches per query (1-20).", ge=1, le=20, title="Limit"), + ] = 10 diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py new file mode 100644 index 0000000..9cdb359 --- /dev/null +++ b/packages/discolike/src/discolike/requests.py @@ -0,0 +1,75 @@ +"""Request models generated from the platform OpenAPI spec by scripts/gen_requests.py.""" + +from discolike._generated.requests import AppendParams +from discolike._generated.requests import BulkContactMatchQueryItem +from discolike._generated.requests import BulkContactMatchRequest +from discolike._generated.requests import CompaniesDataParams +from discolike._generated.requests import CompaniesExtractParams +from discolike._generated.requests import CompaniesGrowthParams +from discolike._generated.requests import CompaniesPublicLinksParams +from discolike._generated.requests import CompaniesRedirectsParams +from discolike._generated.requests import CompaniesScoreParams +from discolike._generated.requests import CompaniesSubsidiariesParams +from discolike._generated.requests import CompaniesVendorsParams +from discolike._generated.requests import ContactFilters +from discolike._generated.requests import ContactGenerateRequest +from discolike._generated.requests import ContactsCountParams +from discolike._generated.requests import ContactsLookupParams +from discolike._generated.requests import ContactsMatchParams +from discolike._generated.requests import ContactsSearchParams +from discolike._generated.requests import CountParams +from discolike._generated.requests import CreateExclusionListRequest +from discolike._generated.requests import DiscoGenPersonaProcessRequest +from discolike._generated.requests import DiscoGenProcessRequest +from discolike._generated.requests import DiscoverParams +from discolike._generated.requests import FindEmailBatchRequest +from discolike._generated.requests import FindEmailRequest +from discolike._generated.requests import LLMProviderCreateRequest +from discolike._generated.requests import LLMProviderUpdateRequest +from discolike._generated.requests import MatchBulkParams +from discolike._generated.requests import MatchCompanyParams +from discolike._generated.requests import QueriesListParams +from discolike._generated.requests import SaveResultsRequest +from discolike._generated.requests import SearchProviderRequest +from discolike._generated.requests import SegmentFileParams +from discolike._generated.requests import SegmentParams +from discolike._generated.requests import UpdateQueryRequest +from discolike._generated.requests import ValidateIcpRequest + +__all__ = [ + "AppendParams", + "BulkContactMatchQueryItem", + "BulkContactMatchRequest", + "CompaniesDataParams", + "CompaniesExtractParams", + "CompaniesGrowthParams", + "CompaniesPublicLinksParams", + "CompaniesRedirectsParams", + "CompaniesScoreParams", + "CompaniesSubsidiariesParams", + "CompaniesVendorsParams", + "ContactFilters", + "ContactGenerateRequest", + "ContactsCountParams", + "ContactsLookupParams", + "ContactsMatchParams", + "ContactsSearchParams", + "CountParams", + "CreateExclusionListRequest", + "DiscoGenPersonaProcessRequest", + "DiscoGenProcessRequest", + "DiscoverParams", + "FindEmailBatchRequest", + "FindEmailRequest", + "LLMProviderCreateRequest", + "LLMProviderUpdateRequest", + "MatchBulkParams", + "MatchCompanyParams", + "QueriesListParams", + "SaveResultsRequest", + "SearchProviderRequest", + "SegmentFileParams", + "SegmentParams", + "UpdateQueryRequest", + "ValidateIcpRequest", +] diff --git a/packages/discolike/tests/test_requests_module.py b/packages/discolike/tests/test_requests_module.py new file mode 100644 index 0000000..dc59da7 --- /dev/null +++ b/packages/discolike/tests/test_requests_module.py @@ -0,0 +1,40 @@ +import pydantic +import pytest + +import discolike.requests as requests_module +from discolike._models import DiscolikeRequest +from discolike.requests import DiscoverParams +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import MatchCompanyParams + + +def test_every_exported_name_is_a_request_model(): + for name in requests_module.__all__: + assert issubclass(getattr(requests_module, name), DiscolikeRequest), name + + +def test_all_is_sorted_and_complete(): + public = sorted( + name + for name, value in vars(requests_module).items() + if isinstance(value, type) and issubclass(value, DiscolikeRequest) + ) + assert list(requests_module.__all__) == public + + +def test_constraint_violations_fail_before_any_request(): + with pytest.raises(pydantic.ValidationError, match="min_similarity"): + DiscoverParams(min_similarity=200) + with pytest.raises(pydantic.ValidationError, match="name"): + MatchCompanyParams.model_validate({}) + + +def test_required_nullable_field_survives_generation(): + request = LLMProviderUpdateRequest(integration_name="n", provider="p", model_name="m", api_key=None) + assert request.to_wire()["api_key"] is None + with pytest.raises(pydantic.ValidationError, match="api_key"): + LLMProviderUpdateRequest.model_validate({"integration_name": "n", "provider": "p", "model_name": "m"}) + + +def test_models_allow_extra_fields(): + assert MatchCompanyParams.model_validate({"name": "Acme", "future_flag": 1}).to_wire()["future_flag"] == 1 From f268a0da0806b0e21336462ddc8a6c714e306a6f Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:49:22 -0700 Subject: [PATCH 06/28] feat(sdk)!: companies methods take generated request models Enum-valued params (match mode, public link source) are now rejected locally with a pydantic ValidationError instead of a server 422. --- .../src/discolike/resources/companies.py | 92 +++++++++---------- packages/discolike/tests/test_companies.py | 80 ++++++++++------ 2 files changed, 95 insertions(+), 77 deletions(-) diff --git a/packages/discolike/src/discolike/resources/companies.py b/packages/discolike/src/discolike/resources/companies.py index 834e123..5d62bf0 100644 --- a/packages/discolike/src/discolike/resources/companies.py +++ b/packages/discolike/src/discolike/resources/companies.py @@ -3,6 +3,14 @@ import pydantic from discolike._models import DiscolikeModel +from discolike.requests import CompaniesDataParams +from discolike.requests import CompaniesExtractParams +from discolike.requests import CompaniesGrowthParams +from discolike.requests import CompaniesPublicLinksParams +from discolike.requests import CompaniesRedirectsParams +from discolike.requests import CompaniesScoreParams +from discolike.requests import CompaniesSubsidiariesParams +from discolike.requests import CompaniesVendorsParams from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -114,91 +122,79 @@ class PublicLink(DiscolikeModel): class CompaniesResource(SyncAPIResource): @api_route("GET", "/bizdata") - def data(self, *, domain: str) -> BizData: - params = {k: v for k, v in locals().items() if k != "self"} - return BizData.model_validate(self._transport.request("GET", "/bizdata", params=params).json()) + def data(self, params: CompaniesDataParams) -> BizData: + return BizData.model_validate(self._transport.request("GET", "/bizdata", params=params.to_wire()).json()) @api_route("GET", "/score") - def score(self, *, domain: str) -> Score: - params = {k: v for k, v in locals().items() if k != "self"} - return Score.model_validate(self._transport.request("GET", "/score", params=params).json()) + def score(self, params: CompaniesScoreParams) -> Score: + return Score.model_validate(self._transport.request("GET", "/score", params=params.to_wire()).json()) @api_route("GET", "/growth") - def growth(self, *, domain: str) -> Growth: - params = {k: v for k, v in locals().items() if k != "self"} - return Growth.model_validate(self._transport.request("GET", "/growth", params=params).json()) + def growth(self, params: CompaniesGrowthParams) -> Growth: + return Growth.model_validate(self._transport.request("GET", "/growth", params=params.to_wire()).json()) @api_route("GET", "/extract") - def extract(self, *, url: str | None = None, domain: str | None = None) -> ExtractResult: - params = {k: v for k, v in locals().items() if k != "self"} - return ExtractResult.model_validate(self._transport.request("GET", "/extract", params=params).json()) + def extract(self, params: CompaniesExtractParams) -> ExtractResult: + return ExtractResult.model_validate(self._transport.request("GET", "/extract", params=params.to_wire()).json()) @api_route("GET", "/redirects") - def redirects(self, *, domain: str, match: str | None = None) -> list[Redirect]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/redirects", params=params).json() + def redirects(self, params: CompaniesRedirectsParams) -> list[Redirect]: + rows = self._transport.request("GET", "/redirects", params=params.to_wire()).json() return [Redirect.model_validate(row) for row in rows] @api_route("GET", "/vendors") - def vendors(self, *, domain: str, match: str | None = None) -> list[Vendor]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/vendors", params=params).json() + def vendors(self, params: CompaniesVendorsParams) -> list[Vendor]: + rows = self._transport.request("GET", "/vendors", params=params.to_wire()).json() return [Vendor.model_validate(row) for row in rows] @api_route("GET", "/subsidiaries") - def subsidiaries(self, *, domain: str, match: str | None = None) -> list[Subsidiary]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/subsidiaries", params=params).json() + def subsidiaries(self, params: CompaniesSubsidiariesParams) -> list[Subsidiary]: + rows = self._transport.request("GET", "/subsidiaries", params=params.to_wire()).json() return [Subsidiary.model_validate(row) for row in rows] @api_route("GET", "/publiclink") - def public_links(self, *, domain: str, source: str) -> list[PublicLink]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/publiclink", params=params).json() + def public_links(self, params: CompaniesPublicLinksParams) -> list[PublicLink]: + rows = self._transport.request("GET", "/publiclink", params=params.to_wire()).json() return [PublicLink.model_validate(row) for row in rows] class AsyncCompaniesResource(AsyncAPIResource): @api_route("GET", "/bizdata") - async def data(self, *, domain: str) -> BizData: - params = {k: v for k, v in locals().items() if k != "self"} - return BizData.model_validate((await self._transport.request("GET", "/bizdata", params=params)).json()) + async def data(self, params: CompaniesDataParams) -> BizData: + response = await self._transport.request("GET", "/bizdata", params=params.to_wire()) + return BizData.model_validate(response.json()) @api_route("GET", "/score") - async def score(self, *, domain: str) -> Score: - params = {k: v for k, v in locals().items() if k != "self"} - return Score.model_validate((await self._transport.request("GET", "/score", params=params)).json()) + async def score(self, params: CompaniesScoreParams) -> Score: + response = await self._transport.request("GET", "/score", params=params.to_wire()) + return Score.model_validate(response.json()) @api_route("GET", "/growth") - async def growth(self, *, domain: str) -> Growth: - params = {k: v for k, v in locals().items() if k != "self"} - return Growth.model_validate((await self._transport.request("GET", "/growth", params=params)).json()) + async def growth(self, params: CompaniesGrowthParams) -> Growth: + response = await self._transport.request("GET", "/growth", params=params.to_wire()) + return Growth.model_validate(response.json()) @api_route("GET", "/extract") - async def extract(self, *, url: str | None = None, domain: str | None = None) -> ExtractResult: - params = {k: v for k, v in locals().items() if k != "self"} - return ExtractResult.model_validate((await self._transport.request("GET", "/extract", params=params)).json()) + async def extract(self, params: CompaniesExtractParams) -> ExtractResult: + response = await self._transport.request("GET", "/extract", params=params.to_wire()) + return ExtractResult.model_validate(response.json()) @api_route("GET", "/redirects") - async def redirects(self, *, domain: str, match: str | None = None) -> list[Redirect]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/redirects", params=params)).json() + async def redirects(self, params: CompaniesRedirectsParams) -> list[Redirect]: + rows = (await self._transport.request("GET", "/redirects", params=params.to_wire())).json() return [Redirect.model_validate(row) for row in rows] @api_route("GET", "/vendors") - async def vendors(self, *, domain: str, match: str | None = None) -> list[Vendor]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/vendors", params=params)).json() + async def vendors(self, params: CompaniesVendorsParams) -> list[Vendor]: + rows = (await self._transport.request("GET", "/vendors", params=params.to_wire())).json() return [Vendor.model_validate(row) for row in rows] @api_route("GET", "/subsidiaries") - async def subsidiaries(self, *, domain: str, match: str | None = None) -> list[Subsidiary]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/subsidiaries", params=params)).json() + async def subsidiaries(self, params: CompaniesSubsidiariesParams) -> list[Subsidiary]: + rows = (await self._transport.request("GET", "/subsidiaries", params=params.to_wire())).json() return [Subsidiary.model_validate(row) for row in rows] @api_route("GET", "/publiclink") - async def public_links(self, *, domain: str, source: str) -> list[PublicLink]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/publiclink", params=params)).json() + async def public_links(self, params: CompaniesPublicLinksParams) -> list[PublicLink]: + rows = (await self._transport.request("GET", "/publiclink", params=params.to_wire())).json() return [PublicLink.model_validate(row) for row in rows] diff --git a/packages/discolike/tests/test_companies.py b/packages/discolike/tests/test_companies.py index 42971ef..d4cc769 100644 --- a/packages/discolike/tests/test_companies.py +++ b/packages/discolike/tests/test_companies.py @@ -1,26 +1,35 @@ import httpx2 +import pydantic import pytest +from discolike.requests import CompaniesDataParams +from discolike.requests import CompaniesExtractParams +from discolike.requests import CompaniesGrowthParams +from discolike.requests import CompaniesPublicLinksParams +from discolike.requests import CompaniesRedirectsParams +from discolike.requests import CompaniesScoreParams +from discolike.requests import CompaniesSubsidiariesParams +from discolike.requests import CompaniesVendorsParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory CASES = [ - ("data", {"domain": "acme.com"}, "/v1/bizdata"), - ("score", {"domain": "acme.com"}, "/v1/score"), - ("growth", {"domain": "acme.com"}, "/v1/growth"), - ("extract", {"url": "https://acme.com/about"}, "/v1/extract"), + ("data", CompaniesDataParams(domain="acme.com"), "/v1/bizdata"), + ("score", CompaniesScoreParams(domain="acme.com"), "/v1/score"), + ("growth", CompaniesGrowthParams(domain="acme.com"), "/v1/growth"), + ("extract", CompaniesExtractParams(url="https://acme.com/about"), "/v1/extract"), ] LIST_CASES = [ - ("redirects", {"domain": "acme.com"}, "/v1/redirects"), - ("vendors", {"domain": "acme.com"}, "/v1/vendors"), - ("subsidiaries", {"domain": "acme.com"}, "/v1/subsidiaries"), - ("public_links", {"domain": "acme.com", "source": "email"}, "/v1/publiclink"), + ("redirects", CompaniesRedirectsParams(domain="acme.com"), "/v1/redirects"), + ("vendors", CompaniesVendorsParams(domain="acme.com", match="vendor"), "/v1/vendors"), + ("subsidiaries", CompaniesSubsidiariesParams(domain="acme.com"), "/v1/subsidiaries"), + ("public_links", CompaniesPublicLinksParams(domain="acme.com", source="email"), "/v1/publiclink"), ] -@pytest.mark.parametrize(("method", "kwargs", "path"), CASES) -def test_companies_methods(method: str, kwargs: dict, path: str, make_client: ClientFactory) -> None: +@pytest.mark.parametrize(("method", "params", "path"), CASES) +def test_companies_methods(method: str, params, path: str, make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -29,18 +38,15 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com", "anything": 1}) with make_client(handler) as client: - result = getattr(client.companies, method)(**kwargs) + result = getattr(client.companies, method)(params) assert seen["path"] == path - for key, value in kwargs.items(): - assert seen["params"][key] == str(value) + assert seen["params"] == {key: str(value) for key, value in params.to_wire().items()} assert result.model_extra["anything"] == 1 -@pytest.mark.parametrize(("method", "kwargs", "path"), CASES) -async def test_companies_methods_async( - method: str, kwargs: dict, path: str, make_async_client: AsyncClientFactory -) -> None: +@pytest.mark.parametrize(("method", "params", "path"), CASES) +async def test_companies_methods_async(method: str, params, path: str, make_async_client: AsyncClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -49,16 +55,15 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com", "anything": 1}) async with make_async_client(handler) as client: - result = await getattr(client.companies, method)(**kwargs) + result = await getattr(client.companies, method)(params) assert seen["path"] == path - for key, value in kwargs.items(): - assert seen["params"][key] == str(value) + assert seen["params"] == {key: str(value) for key, value in params.to_wire().items()} assert result.model_extra["anything"] == 1 -@pytest.mark.parametrize(("method", "kwargs", "path"), LIST_CASES) -def test_companies_list_methods(method: str, kwargs: dict, path: str, make_client: ClientFactory) -> None: +@pytest.mark.parametrize(("method", "params", "path"), LIST_CASES) +def test_companies_list_methods(method: str, params, path: str, make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -67,25 +72,24 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"linked_domain": "acme.io", "anything": 1}]) with make_client(handler) as client: - result = getattr(client.companies, method)(**kwargs) + result = getattr(client.companies, method)(params) assert seen["path"] == path - for key, value in kwargs.items(): - assert seen["params"][key] == str(value) + assert seen["params"] == {key: str(value) for key, value in params.to_wire().items()} assert len(result) == 1 assert result[0].linked_domain == "acme.io" assert result[0].model_extra["anything"] == 1 -@pytest.mark.parametrize(("method", "kwargs", "path"), LIST_CASES) +@pytest.mark.parametrize(("method", "params", "path"), LIST_CASES) async def test_companies_list_methods_async( - method: str, kwargs: dict, path: str, make_async_client: AsyncClientFactory + method: str, params, path: str, make_async_client: AsyncClientFactory ) -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"linked_domain": "acme.io"}]) async with make_async_client(handler) as client: - result = await getattr(client.companies, method)(**kwargs) + result = await getattr(client.companies, method)(params) assert len(result) == 1 assert result[0].linked_domain == "acme.io" @@ -96,7 +100,25 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"language": "en", "text": "Acme makes widgets."}) with make_client(handler) as client: - result = client.companies.extract(domain="acme.com") + result = client.companies.extract(CompaniesExtractParams(domain="acme.com")) assert result.text == "Acme makes widgets." assert result.language == "en" + + +def test_unset_match_mode_is_not_sent(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = dict(httpx2.QueryParams(request.url.query)) + return httpx2.Response(200, json=[]) + + with make_client(handler) as client: + client.companies.redirects(CompaniesRedirectsParams(domain="acme.com")) + + assert seen["params"] == {"domain": "acme.com"} + + +def test_invalid_match_mode_fails_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="match"): + CompaniesRedirectsParams.model_validate({"domain": "acme.com", "match": "loose"}) From 9050ed39b80fcae0a75e45eae265d19c9af99199 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:50:18 -0700 Subject: [PATCH 07/28] feat(sdk)!: match methods take generated request models min_match_confidence's 50-100 range is now enforced client-side; the hand-written 'only send it when set' branch goes away because to_wire already sends only what the caller set. --- .../src/discolike/resources/match.py | 101 +++--------------- packages/discolike/tests/test_match.py | 49 ++++++--- 2 files changed, 49 insertions(+), 101 deletions(-) diff --git a/packages/discolike/src/discolike/resources/match.py b/packages/discolike/src/discolike/resources/match.py index f507d26..7f728d0 100644 --- a/packages/discolike/src/discolike/resources/match.py +++ b/packages/discolike/src/discolike/resources/match.py @@ -1,15 +1,15 @@ from __future__ import annotations -import pathlib -from typing import BinaryIO - import pydantic from discolike._jobs import FAMILY_BULKMATCH from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel +from discolike.requests import MatchBulkParams +from discolike.requests import MatchCompanyParams from discolike.resources._base import AsyncAPIResource +from discolike.resources._base import FileInput from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route from discolike.resources._base import open_upload @@ -36,52 +36,16 @@ class MatchResponse(DiscolikeModel): class MatchResource(SyncAPIResource): @api_route("GET", "/match") - def company( - self, - *, - name: str, - phone: str | None = None, - city: str | None = None, - state: str | None = None, - country: str | None = None, - zip_code: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> MatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - return MatchResponse.model_validate(self._transport.request("GET", "/match", params=params).json()) + def company(self, params: MatchCompanyParams) -> MatchResponse: + return MatchResponse.model_validate(self._transport.request("GET", "/match", params=params.to_wire()).json()) @api_route("POST", "/bulkmatch") - def bulk( - self, - *, - file: pathlib.Path | str | BinaryIO, - name_column: str, - phone_column: str | None = None, - city_column: str | None = None, - state_column: str | None = None, - country_column: str | None = None, - zip_code_column: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> Job: - params = { - "name_column": name_column, - "phone_column": phone_column, - "city_column": city_column, - "state_column": state_column, - "country_column": country_column, - "zip_code_column": zip_code_column, - "strict": strict, - "local_mode": local_mode, - } - if min_match_confidence is not None: - params["min_match_confidence"] = min_match_confidence + def bulk(self, params: MatchBulkParams, *, file: FileInput) -> Job: filename, fh, we_opened_it = open_upload(file) try: - response = self._transport.request("POST", "/bulkmatch", params=params, files={"file": (filename, fh)}) + response = self._transport.request( + "POST", "/bulkmatch", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() @@ -90,53 +54,16 @@ def bulk( class AsyncMatchResource(AsyncAPIResource): @api_route("GET", "/match") - async def company( - self, - *, - name: str, - phone: str | None = None, - city: str | None = None, - state: str | None = None, - country: str | None = None, - zip_code: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> MatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - return MatchResponse.model_validate((await self._transport.request("GET", "/match", params=params)).json()) + async def company(self, params: MatchCompanyParams) -> MatchResponse: + response = await self._transport.request("GET", "/match", params=params.to_wire()) + return MatchResponse.model_validate(response.json()) @api_route("POST", "/bulkmatch") - async def bulk( - self, - *, - file: pathlib.Path | str | BinaryIO, - name_column: str, - phone_column: str | None = None, - city_column: str | None = None, - state_column: str | None = None, - country_column: str | None = None, - zip_code_column: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> AsyncJob: - params = { - "name_column": name_column, - "phone_column": phone_column, - "city_column": city_column, - "state_column": state_column, - "country_column": country_column, - "zip_code_column": zip_code_column, - "strict": strict, - "local_mode": local_mode, - } - if min_match_confidence is not None: - params["min_match_confidence"] = min_match_confidence + async def bulk(self, params: MatchBulkParams, *, file: FileInput) -> AsyncJob: filename, fh, we_opened_it = open_upload(file) try: response = await self._transport.request( - "POST", "/bulkmatch", params=params, files={"file": (filename, fh)} + "POST", "/bulkmatch", params=params.to_wire(), files={"file": (filename, fh)} ) finally: if we_opened_it: diff --git a/packages/discolike/tests/test_match.py b/packages/discolike/tests/test_match.py index df1dcd0..bbec141 100644 --- a/packages/discolike/tests/test_match.py +++ b/packages/discolike/tests/test_match.py @@ -1,10 +1,14 @@ import io import httpx2 +import pydantic +import pytest from discolike._jobs import FAMILY_BULKMATCH from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import MatchBulkParams +from discolike.requests import MatchCompanyParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -18,17 +22,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com", "anything": 1}) with make_client(handler) as client: - result = client.match.company(name="Acme Inc", city="Austin", strict=True, min_match_confidence=80) + result = client.match.company( + MatchCompanyParams(name="Acme Inc", city="Austin", strict=True, min_match_confidence=80) + ) assert seen["path"] == "/v1/match" - assert seen["params"]["name"] == "Acme Inc" - assert seen["params"]["city"] == "Austin" - assert seen["params"]["strict"] == "true" - assert seen["params"]["min_match_confidence"] == "80" + assert seen["params"] == {"name": "Acme Inc", "city": "Austin", "strict": "true", "min_match_confidence": "80"} assert result.model_extra["anything"] == 1 # ty: ignore[not-subscriptable] -def test_company_omits_min_match_confidence_when_not_set(make_client: ClientFactory) -> None: +def test_company_omits_everything_not_set(make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -36,9 +39,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com"}) with make_client(handler) as client: - client.match.company(name="Acme Inc") + client.match.company(MatchCompanyParams(name="Acme Inc")) - assert "min_match_confidence" not in seen["params"] + assert seen["params"] == {"name": "Acme Inc"} + + +def test_company_rejects_out_of_range_confidence_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="min_match_confidence"): + MatchCompanyParams(name="Acme Inc", min_match_confidence=10) def test_bulk_posts_multipart_with_path(tmp_path, make_client: ClientFactory) -> None: @@ -53,11 +61,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "bm-1"}) with make_client(handler) as client: - job = client.match.bulk(file=csv_path, name_column="company", min_match_confidence=80) + job = client.match.bulk(MatchBulkParams(name_column="company", min_match_confidence=80), file=csv_path) assert seen["path"] == "/v1/bulkmatch" - assert seen["params"]["name_column"] == "company" - assert seen["params"]["min_match_confidence"] == "80" + assert seen["params"] == {"name_column": "company", "min_match_confidence": "80"} assert b"Acme" in seen["content"] assert isinstance(job, Job) assert job.task_family == FAMILY_BULKMATCH @@ -72,12 +79,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "bm-2"}) with make_client(handler) as client: - job = client.match.bulk(file=handle, name_column="company") + job = client.match.bulk(MatchBulkParams(name_column="company"), file=handle) assert job.task_id == "bm-2" assert handle.closed is False +async def test_async_company(make_async_client: AsyncClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = dict(httpx2.QueryParams(request.url.query)) + return httpx2.Response(200, json={"matches": []}) + + async with make_async_client(handler) as client: + result = await client.match.company(MatchCompanyParams(name="Acme Inc", local_mode=True)) + + assert seen["params"] == {"name": "Acme Inc", "local_mode": "true"} + assert result.matches == [] + + async def test_async_bulk_posts_multipart(tmp_path, make_async_client: AsyncClientFactory) -> None: csv_path = tmp_path / "companies.csv" csv_path.write_text("company\nAcme\n") @@ -90,10 +111,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "bm-3"}) async with make_async_client(handler) as client: - job = await client.match.bulk(file=csv_path, name_column="company") + job = await client.match.bulk(MatchBulkParams(name_column="company"), file=csv_path) assert seen["path"] == "/v1/bulkmatch" - assert seen["params"]["name_column"] == "company" + assert seen["params"] == {"name_column": "company"} assert b"Acme" in seen["content"] assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_BULKMATCH From 94b74f750a0082246768f272b589f44dd2148c8b Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:54:24 -0700 Subject: [PATCH 08/28] feat(sdk)!: contacts methods take generated request models The 39-parameter discover/search signatures collapse into ContactFilters / ContactsSearchParams, and bulk_match now validates each query item instead of forwarding raw dicts. --- .../src/discolike/resources/contacts.py | 373 ++---------------- packages/discolike/tests/test_contacts.py | 77 ++-- 2 files changed, 90 insertions(+), 360 deletions(-) diff --git a/packages/discolike/src/discolike/resources/contacts.py b/packages/discolike/src/discolike/resources/contacts.py index 9fb54e4..98c801d 100644 --- a/packages/discolike/src/discolike/resources/contacts.py +++ b/packages/discolike/src/discolike/resources/contacts.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Any - import pydantic from discolike._jobs import FAMILY_CONTACTMATCH @@ -9,7 +7,13 @@ from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import BulkContactMatchRequest +from discolike.requests import ContactFilters +from discolike.requests import ContactGenerateRequest +from discolike.requests import ContactsCountParams +from discolike.requests import ContactsLookupParams +from discolike.requests import ContactsMatchParams +from discolike.requests import ContactsSearchParams from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -61,374 +65,73 @@ class ContactsDiscoverResponse(DiscolikeModel): class ContactsResource(SyncAPIResource): @api_route("GET", "/contacts") - def search( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - ) -> list[Contact]: - params = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("GET", "/contacts", params=params) + def search(self, params: ContactsSearchParams) -> list[Contact]: + response = self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") - def count( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - return Count.model_validate(self._transport.request("GET", "/contacts/count", params=params).json()) + def count(self, params: ContactsCountParams) -> Count: + return Count.model_validate(self._transport.request("GET", "/contacts/count", params=params.to_wire()).json()) @api_route("GET", "/contacts/lookup") - def lookup( - self, *, persona_id: int | None = None, linkedin: str | None = None, email: str | None = None - ) -> Contact: - params = {k: v for k, v in locals().items() if k != "self"} - return Contact.model_validate(self._transport.request("GET", "/contacts/lookup", params=params).json()) + def lookup(self, params: ContactsLookupParams) -> Contact: + return Contact.model_validate( + self._transport.request("GET", "/contacts/lookup", params=params.to_wire()).json() + ) @api_route("GET", "/contacts/match") - def match( - self, - *, - name: str, - company_name: str | None = None, - domain: str | None = None, - person_country: str | None = None, - limit: int | None = None, - ) -> ContactMatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - return ContactMatchResponse.model_validate( - self._transport.request("GET", "/contacts/match", params=params).json() - ) + def match(self, params: ContactsMatchParams) -> ContactMatchResponse: + response = self._transport.request("GET", "/contacts/match", params=params.to_wire()) + return ContactMatchResponse.model_validate(response.json()) @api_route("POST", "/contacts/bulk-match") - def bulk_match( - self, - *, - queries: list[dict[str, Any]], - enrich: bool | None = None, - limit: int | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/contacts/bulk-match", json_body=drop_none(body)) + def bulk_match(self, request: BulkContactMatchRequest) -> Job: + response = self._transport.request("POST", "/contacts/bulk-match", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_CONTACTMATCH, task_id=response.json()["task_id"]) @api_route("POST", "/contacts/discover") - def discover( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - results_by_company: int | None = None, - include_search_contacts: bool | None = None, - consensus: int | None = None, - ) -> ContactsDiscoverResponse: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/contacts/discover", json_body=drop_none(body)) + def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: + response = self._transport.request("POST", "/contacts/discover", json_body=request.to_wire()) return ContactsDiscoverResponse.model_validate(response.json()) @api_route("POST", "/contacts/discover/generate") - def generate( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - max_contacts_per_domain: int | None = None, - max_company_records: int | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/contacts/discover/generate", json_body=drop_none(body)) + def generate(self, request: ContactGenerateRequest) -> Job: + response = self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) class AsyncContactsResource(AsyncAPIResource): @api_route("GET", "/contacts") - async def search( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - ) -> list[Contact]: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts", params=params) + async def search(self, params: ContactsSearchParams) -> list[Contact]: + response = await self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") - async def count( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts/count", params=params) + async def count(self, params: ContactsCountParams) -> Count: + response = await self._transport.request("GET", "/contacts/count", params=params.to_wire()) return Count.model_validate(response.json()) @api_route("GET", "/contacts/lookup") - async def lookup( - self, *, persona_id: int | None = None, linkedin: str | None = None, email: str | None = None - ) -> Contact: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts/lookup", params=params) + async def lookup(self, params: ContactsLookupParams) -> Contact: + response = await self._transport.request("GET", "/contacts/lookup", params=params.to_wire()) return Contact.model_validate(response.json()) @api_route("GET", "/contacts/match") - async def match( - self, - *, - name: str, - company_name: str | None = None, - domain: str | None = None, - person_country: str | None = None, - limit: int | None = None, - ) -> ContactMatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts/match", params=params) + async def match(self, params: ContactsMatchParams) -> ContactMatchResponse: + response = await self._transport.request("GET", "/contacts/match", params=params.to_wire()) return ContactMatchResponse.model_validate(response.json()) @api_route("POST", "/contacts/bulk-match") - async def bulk_match( - self, - *, - queries: list[dict[str, Any]], - enrich: bool | None = None, - limit: int | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/contacts/bulk-match", json_body=drop_none(body)) + async def bulk_match(self, request: BulkContactMatchRequest) -> AsyncJob: + response = await self._transport.request("POST", "/contacts/bulk-match", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_CONTACTMATCH, task_id=response.json()["task_id"]) @api_route("POST", "/contacts/discover") - async def discover( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - results_by_company: int | None = None, - include_search_contacts: bool | None = None, - consensus: int | None = None, - ) -> ContactsDiscoverResponse: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/contacts/discover", json_body=drop_none(body)) + async def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: + response = await self._transport.request("POST", "/contacts/discover", json_body=request.to_wire()) return ContactsDiscoverResponse.model_validate(response.json()) @api_route("POST", "/contacts/discover/generate") - async def generate( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - max_contacts_per_domain: int | None = None, - max_company_records: int | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/contacts/discover/generate", json_body=drop_none(body)) + async def generate(self, request: ContactGenerateRequest) -> AsyncJob: + response = await self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) diff --git a/packages/discolike/tests/test_contacts.py b/packages/discolike/tests/test_contacts.py index 50a661d..ba13d2c 100644 --- a/packages/discolike/tests/test_contacts.py +++ b/packages/discolike/tests/test_contacts.py @@ -3,11 +3,20 @@ import json import httpx2 +import pydantic +import pytest from discolike._jobs import FAMILY_CONTACTMATCH from discolike._jobs import FAMILY_DISCOGEN from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import BulkContactMatchRequest +from discolike.requests import ContactFilters +from discolike.requests import ContactGenerateRequest +from discolike.requests import ContactsCountParams +from discolike.requests import ContactsLookupParams +from discolike.requests import ContactsMatchParams +from discolike.requests import ContactsSearchParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -26,10 +35,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: results = client.contacts.search( - seniority=["vp", "director"], - domain=["acme.com"], - jobstart_date="2025-01-01,2025-06-30", - max_records=25, + ContactsSearchParams( + seniority=["vp", "director"], + domain=["acme.com"], + jobstart_date="2025-01-01,2025-06-30", + max_records=25, + ) ) assert seen["path"] == "/v1/contacts" @@ -38,10 +49,21 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen["params"]["domain"] == "acme.com" assert seen["params"]["jobstart_date"] == "2025-01-01,2025-06-30" assert seen["params"]["max_records"] == "25" + assert "has_email" not in seen["params"] assert results[0].persona_id == 1 assert results[0].model_extra["extra_field"] == "kept" # ty: ignore[not-subscriptable] +def test_search_rejects_unknown_seniority_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="seniority"): + ContactsSearchParams.model_validate({"seniority": ["intern"]}) + + +def test_search_rejects_max_records_below_floor_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="max_records"): + ContactsSearchParams(max_records=10) + + def test_count(make_client: ClientFactory) -> None: seen = {} @@ -52,7 +74,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"count": 1234}) with make_client(handler) as client: - result = client.contacts.count(seniority=["vp"], has_email=True, jobstart_date="2025-01-01") + result = client.contacts.count( + ContactsCountParams(seniority=["vp"], has_email=True, jobstart_date="2025-01-01") + ) assert seen["path"] == "/v1/contacts/count" assert seen["method"] == "GET" @@ -68,13 +92,10 @@ def test_lookup(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: seen["path"] = request.url.path seen["params"] = httpx2.QueryParams(request.url.query) - return httpx2.Response( - 200, - json={"persona_id": 12345678, "name": "Jane Doe", "domain": "example.com"}, - ) + return httpx2.Response(200, json={"persona_id": 12345678, "name": "Jane Doe", "domain": "example.com"}) with make_client(handler) as client: - result = client.contacts.lookup(persona_id=12345678, email="jane@example.com") + result = client.contacts.lookup(ContactsLookupParams(persona_id=12345678, email="jane@example.com")) assert seen["path"] == "/v1/contacts/lookup" assert seen["params"]["persona_id"] == "12345678" @@ -107,7 +128,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) with make_client(handler) as client: - result = client.contacts.match(name="Jane Doe", company_name="Acme Corp", limit=5) + result = client.contacts.match(ContactsMatchParams(name="Jane Doe", company_name="Acme Corp", limit=5)) assert seen["path"] == "/v1/contacts/match" assert seen["params"]["name"] == "Jane Doe" @@ -128,9 +149,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.contacts.bulk_match( - queries=[{"name": "Jane Doe", "company_name": "Acme Corp"}], - enrich=True, - limit=5, + BulkContactMatchRequest.model_validate( + {"queries": [{"name": "Jane Doe", "company_name": "Acme Corp"}], "enrich": True, "limit": 5} + ) ) assert seen["path"] == "/v1/contacts/bulk-match" @@ -163,11 +184,13 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.contacts.discover( - domain=["acme.com"], - seniority=["vp"], - jobstart_date="2025-01-01", - results_by_company=10, - consensus=2, + ContactFilters( + domain=["acme.com"], + seniority=["vp"], + jobstart_date="2025-01-01", + results_by_company=10, + consensus=2, + ) ) assert seen["path"] == "/v1/contacts/discover" @@ -195,9 +218,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.contacts.generate( - icp_text="VPs of Marketing at B2B SaaS", - domains=["gusto.com", "rippling.com"], - context_mode="website", + ContactGenerateRequest( + icp_text="VPs of Marketing at B2B SaaS", + domains=["gusto.com", "rippling.com"], + context_mode="website", + ) ) assert seen["path"] == "/v1/contacts/discover/generate" @@ -217,7 +242,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"persona_id": 2, "domain": "b.com"}]) async with make_async_client(handler) as client: - results = await client.contacts.search(domain=["b.com"]) + results = await client.contacts.search(ContactsSearchParams(domain=["b.com"])) assert results[0].persona_id == 2 @@ -226,7 +251,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "cm-2"}) async with make_async_client(handler) as client: - job = await client.contacts.bulk_match(queries=[{"name": "Jane Doe"}]) + job = await client.contacts.bulk_match( + BulkContactMatchRequest.model_validate({"queries": [{"name": "Jane Doe"}]}) + ) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_CONTACTMATCH @@ -238,7 +265,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-2"}) async with make_async_client(handler) as client: - job = await client.contacts.generate(icp_text="VPs", domains=["a.com"]) + job = await client.contacts.generate(ContactGenerateRequest(icp_text="VPs", domains=["a.com"])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN From 0a48e029f69fa10863a046d2f4c2dc32e28592df Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:55:28 -0700 Subject: [PATCH 09/28] feat(sdk)!: email methods take generated request models find_batch drops its contacts= shim; FindEmailBatchRequest.requests is the platform's own field name and validates each contact up front. --- .../src/discolike/resources/email.py | 23 ++++++------ packages/discolike/tests/test_email.py | 36 +++++++++++++------ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/discolike/src/discolike/resources/email.py b/packages/discolike/src/discolike/resources/email.py index 719d43f..085d615 100644 --- a/packages/discolike/src/discolike/resources/email.py +++ b/packages/discolike/src/discolike/resources/email.py @@ -10,7 +10,8 @@ from discolike._email import EnumerationMatch from discolike._email import EnumerationOutput from discolike._email import ValidationOutput -from discolike._transport import drop_none +from discolike.requests import FindEmailBatchRequest +from discolike.requests import FindEmailRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -32,14 +33,13 @@ class EmailResource(SyncAPIResource): @api_route("POST", "/email/find") - def find(self, *, first_name: str, last_name: str, domain: str, known_pattern: str | None = None) -> EmailJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/email/find", json_body=drop_none(body)) + def find(self, request: FindEmailRequest) -> EmailJob: + response = self._transport.request("POST", "/email/find", json_body=request.to_wire()) return EmailJob(self._transport, job_id=response.json()["job_id"], kind="find") @api_route("POST", "/email/find/batch") - def find_batch(self, *, contacts: list[dict[str, str]]) -> EmailBatch: - response = self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) + def find_batch(self, request: FindEmailBatchRequest) -> EmailBatch: + response = self._transport.request("POST", "/email/find/batch", json_body=request.to_wire()) return EmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> EmailBatch: @@ -51,16 +51,13 @@ def job(self, job_id: str, *, kind: EmailKind = "find") -> EmailJob: class AsyncEmailResource(AsyncAPIResource): @api_route("POST", "/email/find") - async def find( - self, *, first_name: str, last_name: str, domain: str, known_pattern: str | None = None - ) -> AsyncEmailJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/email/find", json_body=drop_none(body)) + async def find(self, request: FindEmailRequest) -> AsyncEmailJob: + response = await self._transport.request("POST", "/email/find", json_body=request.to_wire()) return AsyncEmailJob(self._transport, job_id=response.json()["job_id"], kind="find") @api_route("POST", "/email/find/batch") - async def find_batch(self, *, contacts: list[dict[str, str]]) -> AsyncEmailBatch: - response = await self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) + async def find_batch(self, request: FindEmailBatchRequest) -> AsyncEmailBatch: + response = await self._transport.request("POST", "/email/find/batch", json_body=request.to_wire()) return AsyncEmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> AsyncEmailBatch: diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index dc52b7e..682b7f2 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -3,10 +3,13 @@ import json import httpx2 +import pydantic import pytest import discolike._jobs as jobs_module from discolike import JobFailedError +from discolike.requests import FindEmailBatchRequest +from discolike.requests import FindEmailRequest from discolike.resources.email import AsyncEmailBatch from discolike.resources.email import AsyncEmailJob from discolike.resources.email import EmailBatch @@ -51,10 +54,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: batch = client.email.find_batch( - contacts=[ - {"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}, - {"first_name": "Alan", "last_name": "Turing", "domain": "acme.com"}, - ] + FindEmailBatchRequest.model_validate( + { + "requests": [ + {"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}, + {"first_name": "Alan", "last_name": "Turing", "domain": "acme.com"}, + ] + } + ) ) assert seen["path"] == "/v1/email/find/batch" @@ -228,7 +235,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=payload) with make_client(handler) as client: - job = client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil") + job = client.email.find(FindEmailRequest(first_name="Grace", last_name="Hopper", domain="navy.mil")) assert isinstance(job, EmailJob) assert job.job_id == "j-9" output = job.wait(timeout=60.0, poll_interval=1.0) @@ -249,8 +256,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(202, json={"job_id": "j-kp", "status": "queued"}) with make_client(handler) as client: - client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil", known_pattern="first.last") - client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil") + client.email.find( + FindEmailRequest(first_name="Grace", last_name="Hopper", domain="navy.mil", known_pattern="first.last") + ) + client.email.find(FindEmailRequest(first_name="Grace", last_name="Hopper", domain="navy.mil")) assert bodies[0] == { "first_name": "Grace", @@ -268,7 +277,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"job_id": "j-x", "status": "failed", "result": None, "error": "boom"}) with make_client(handler) as client: - job = client.email.find(first_name="No", last_name="One", domain="void.dev") + job = client.email.find(FindEmailRequest(first_name="No", last_name="One", domain="void.dev")) with pytest.raises(JobFailedError, match="boom"): job.wait(timeout=60.0) @@ -296,7 +305,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: batch = await client.email.find_batch( - contacts=[{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}] + FindEmailBatchRequest.model_validate( + {"requests": [{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}]} + ) ) assert isinstance(batch, AsyncEmailBatch) @@ -379,7 +390,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=payload) async with make_async_client(handler) as client: - job = await client.email.find(first_name="Ada", last_name="Lovelace", domain="acme.com") + job = await client.email.find(FindEmailRequest(first_name="Ada", last_name="Lovelace", domain="acme.com")) assert isinstance(job, AsyncEmailJob) output = await job.wait(timeout=60.0, poll_interval=1.0) @@ -388,6 +399,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert output.result.tier == 2 +def test_find_batch_rejects_incomplete_contacts_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="last_name"): + FindEmailBatchRequest.model_validate({"requests": [{"first_name": "Ada", "domain": "acme.com"}]}) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.email import EmailResource From d9401f91ed0bf38136414d24135e5c13d2b9e5d5 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:59:07 -0700 Subject: [PATCH 10/28] feat(sdk)!: queries methods take generated request models save_results' action enum and the tag/name length limits now fail locally; query_id stays a keyword argument since path params are not part of the body schema. --- .../src/discolike/resources/queries.py | 104 ++++-------------- packages/discolike/tests/test_queries.py | 29 ++++- 2 files changed, 44 insertions(+), 89 deletions(-) diff --git a/packages/discolike/src/discolike/resources/queries.py b/packages/discolike/src/discolike/resources/queries.py index 1a6dd61..a3ab69a 100644 --- a/packages/discolike/src/discolike/resources/queries.py +++ b/packages/discolike/src/discolike/resources/queries.py @@ -5,7 +5,10 @@ import pydantic from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import CreateExclusionListRequest +from discolike.requests import QueriesListParams +from discolike.requests import SaveResultsRequest +from discolike.requests import UpdateQueryRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -40,55 +43,23 @@ class QueryResult(DiscolikeModel): class QueriesResource(SyncAPIResource): @api_route("GET", "/queries/saved") - def list( - self, - *, - max_records: int | None = None, - offset: int | None = None, - action: str | None = None, - tags: builtins.list[str] | None = None, - ) -> SavedQueries: - params = {k: v for k, v in locals().items() if k != "self"} - return SavedQueries.model_validate(self._transport.request("GET", "/queries/saved", params=params).json()) + def list(self, params: QueriesListParams) -> SavedQueries: + response = self._transport.request("GET", "/queries/saved", params=params.to_wire()) + return SavedQueries.model_validate(response.json()) @api_route("POST", "/queries/exclusion-list") - def create_exclusion_list( - self, - *, - query_name: str, - domains: builtins.list[str] | None = None, - persona_ids: builtins.list[int] | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/queries/exclusion-list", json_body=drop_none(body)) + def create_exclusion_list(self, request: CreateExclusionListRequest) -> QueryResult: + response = self._transport.request("POST", "/queries/exclusion-list", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("POST", "/queries/save-results") - def save_results( - self, - *, - query_name: str, - action: str, - data: builtins.list[dict], - query_params: dict | None = None, - domain_column: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/queries/save-results", json_body=drop_none(body)) + def save_results(self, request: SaveResultsRequest) -> QueryResult: + response = self._transport.request("POST", "/queries/save-results", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("PATCH", "/queries/{query_id}") - def update( - self, - *, - query_id: str, - query_name: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {"query_name": query_name, "tags": tags} - response = self._transport.request("PATCH", f"/queries/{query_id}", json_body=drop_none(body)) + def update(self, request: UpdateQueryRequest, *, query_id: str) -> QueryResult: + response = self._transport.request("PATCH", f"/queries/{query_id}", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("DELETE", "/queries/{query_id}") @@ -98,56 +69,23 @@ def delete(self, *, query_id: str) -> None: class AsyncQueriesResource(AsyncAPIResource): @api_route("GET", "/queries/saved") - async def list( - self, - *, - max_records: int | None = None, - offset: int | None = None, - action: str | None = None, - tags: builtins.list[str] | None = None, - ) -> SavedQueries: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/queries/saved", params=params) + async def list(self, params: QueriesListParams) -> SavedQueries: + response = await self._transport.request("GET", "/queries/saved", params=params.to_wire()) return SavedQueries.model_validate(response.json()) @api_route("POST", "/queries/exclusion-list") - async def create_exclusion_list( - self, - *, - query_name: str, - domains: builtins.list[str] | None = None, - persona_ids: builtins.list[int] | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/queries/exclusion-list", json_body=drop_none(body)) + async def create_exclusion_list(self, request: CreateExclusionListRequest) -> QueryResult: + response = await self._transport.request("POST", "/queries/exclusion-list", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("POST", "/queries/save-results") - async def save_results( - self, - *, - query_name: str, - action: str, - data: builtins.list[dict], - query_params: dict | None = None, - domain_column: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/queries/save-results", json_body=drop_none(body)) + async def save_results(self, request: SaveResultsRequest) -> QueryResult: + response = await self._transport.request("POST", "/queries/save-results", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("PATCH", "/queries/{query_id}") - async def update( - self, - *, - query_id: str, - query_name: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {"query_name": query_name, "tags": tags} - response = await self._transport.request("PATCH", f"/queries/{query_id}", json_body=drop_none(body)) + async def update(self, request: UpdateQueryRequest, *, query_id: str) -> QueryResult: + response = await self._transport.request("PATCH", f"/queries/{query_id}", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("DELETE", "/queries/{query_id}") diff --git a/packages/discolike/tests/test_queries.py b/packages/discolike/tests/test_queries.py index e4d5a90..3255598 100644 --- a/packages/discolike/tests/test_queries.py +++ b/packages/discolike/tests/test_queries.py @@ -3,7 +3,13 @@ import json import httpx2 +import pydantic +import pytest +from discolike.requests import CreateExclusionListRequest +from discolike.requests import QueriesListParams +from discolike.requests import SaveResultsRequest +from discolike.requests import UpdateQueryRequest from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -17,7 +23,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"results": [{"query_id": "q1"}], "count": 1}) with make_client(handler) as client: - result = client.queries.list(max_records=10, offset=5, action="discover", tags=["a", "b"]) + result = client.queries.list(QueriesListParams(max_records=10, offset=5, action="discover", tags=["a", "b"])) assert seen["path"] == "/v1/queries/saved" assert seen["params"]["max_records"] == "10" @@ -38,7 +44,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q2", "query_name": "My List", "domain_count": 2}) with make_client(handler) as client: - result = client.queries.create_exclusion_list(query_name="My List", domains=["a.com", "b.com"]) + result = client.queries.create_exclusion_list( + CreateExclusionListRequest(query_name="My List", domains=["a.com", "b.com"]) + ) assert seen["path"] == "/v1/queries/exclusion-list" assert seen["method"] == "POST" @@ -56,7 +64,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q3", "query_name": "New Name"}) with make_client(handler) as client: - result = client.queries.update(query_id="q3", query_name="New Name") + result = client.queries.update(UpdateQueryRequest(query_name="New Name"), query_id="q3") assert seen["path"] == "/v1/queries/q3" assert seen["method"] == "PATCH" @@ -85,7 +93,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"results": [], "count": 0}) async with make_async_client(handler) as client: - result = await client.queries.list() + result = await client.queries.list(QueriesListParams()) assert result.count == 0 assert result.results == [] @@ -111,7 +119,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q6", "action": "thin_discover", "row_count": 1}) with make_client(handler) as client: - result = client.queries.save_results(query_name="R", action="discover", data=[{"domain": "a.com"}], tags=["x"]) + result = client.queries.save_results( + SaveResultsRequest(query_name="R", action="discover", data=[{"domain": "a.com"}], tags=["x"]) + ) assert seen["path"] == "/v1/queries/save-results" assert seen["method"] == "POST" @@ -126,11 +136,18 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q7"}) async with make_async_client(handler) as client: - result = await client.queries.save_results(query_name="R", action="discover", data=[{"domain": "a.com"}]) + result = await client.queries.save_results( + SaveResultsRequest(query_name="R", action="discover", data=[{"domain": "a.com"}]) + ) assert result.query_id == "q7" +def test_save_results_rejects_unknown_action_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="action"): + SaveResultsRequest.model_validate({"query_name": "R", "action": "bogus", "data": [{"domain": "a.com"}]}) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.queries import QueriesResource From 836b2707c97e05b2ff8302d0c5f6e49782874a4f Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:00:59 -0700 Subject: [PATCH 11/28] feat(sdk)!: provider methods take generated request models LLMProviderUpdateRequest carries the platform's required-but-nullable api_key, so the hand-rolled 'always send api_key' branch is gone: to_wire sends whatever the caller set, null included. --- .../src/discolike/resources/providers.py | 166 +++--------------- packages/discolike/tests/test_providers.py | 67 +++---- 2 files changed, 62 insertions(+), 171 deletions(-) diff --git a/packages/discolike/src/discolike/resources/providers.py b/packages/discolike/src/discolike/resources/providers.py index a15ccc7..2c4d708 100644 --- a/packages/discolike/src/discolike/resources/providers.py +++ b/packages/discolike/src/discolike/resources/providers.py @@ -3,7 +3,9 @@ import pydantic from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import LLMProviderCreateRequest +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import SearchProviderRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -54,38 +56,13 @@ def list(self) -> SearchProviderList: return SearchProviderList.model_validate(self._transport.request("GET", "/search-providers").json()) @api_route("POST", "/search-providers") - def create( - self, - *, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/search-providers", json_body=drop_none(body)) + def create(self, request: SearchProviderRequest) -> SearchProviderConfig: + response = self._transport.request("POST", "/search-providers", json_body=request.to_wire()) return SearchProviderConfig.model_validate(response.json()) @api_route("PUT", "/search-providers/{integration_id}") - def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = { - "integration_name": integration_name, - "provider": provider, - "search_model": search_model, - "api_key": api_key, - "base_url": base_url, - } - response = self._transport.request("PUT", f"/search-providers/{integration_id}", json_body=drop_none(body)) + def update(self, request: SearchProviderRequest, *, integration_id: str) -> SearchProviderConfig: + response = self._transport.request("PUT", f"/search-providers/{integration_id}", json_body=request.to_wire()) return SearchProviderConfig.model_validate(response.json()) @api_route("DELETE", "/search-providers/{integration_id}") @@ -114,39 +91,14 @@ async def list(self) -> SearchProviderList: return SearchProviderList.model_validate(response.json()) @api_route("POST", "/search-providers") - async def create( - self, - *, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/search-providers", json_body=drop_none(body)) + async def create(self, request: SearchProviderRequest) -> SearchProviderConfig: + response = await self._transport.request("POST", "/search-providers", json_body=request.to_wire()) return SearchProviderConfig.model_validate(response.json()) @api_route("PUT", "/search-providers/{integration_id}") - async def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = { - "integration_name": integration_name, - "provider": provider, - "search_model": search_model, - "api_key": api_key, - "base_url": base_url, - } + async def update(self, request: SearchProviderRequest, *, integration_id: str) -> SearchProviderConfig: response = await self._transport.request( - "PUT", f"/search-providers/{integration_id}", json_body=drop_none(body) + "PUT", f"/search-providers/{integration_id}", json_body=request.to_wire() ) return SearchProviderConfig.model_validate(response.json()) @@ -176,17 +128,8 @@ def list(self) -> LLMProviderList: return LLMProviderList.model_validate(self._transport.request("GET", "/llm-providers/config").json()) @api_route("POST", "/llm-providers/config") - def create( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/llm-providers/config", json_body=drop_none(body)) + def create(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = self._transport.request("POST", "/llm-providers/config", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) @api_route("GET", "/llm-providers/config/{integration_id}") @@ -195,26 +138,10 @@ def get(self, *, integration_id: str) -> LLMProviderConfig: return LLMProviderConfig.model_validate(response.json()) @api_route("PUT", "/llm-providers/config/{integration_id}") - def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - model_name: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = drop_none( - { - "integration_name": integration_name, - "provider": provider, - "model_name": model_name, - "base_url": base_url, - } + def update(self, request: LLMProviderUpdateRequest, *, integration_id: str) -> LLMIntegrationResult: + response = self._transport.request( + "PUT", f"/llm-providers/config/{integration_id}", json_body=request.to_wire() ) - body["api_key"] = api_key - response = self._transport.request("PUT", f"/llm-providers/config/{integration_id}", json_body=body) return LLMIntegrationResult.model_validate(response.json()) @api_route("DELETE", "/llm-providers/config/{integration_id}") @@ -227,17 +154,8 @@ def set_default(self, *, integration_id: str) -> LLMIntegrationResult: return LLMIntegrationResult.model_validate(response.json()) @api_route("POST", "/llm-providers/test-connection") - def test_connection( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/llm-providers/test-connection", json_body=drop_none(body)) + def test_connection(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = self._transport.request("POST", "/llm-providers/test-connection", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) @@ -248,17 +166,8 @@ async def list(self) -> LLMProviderList: return LLMProviderList.model_validate(response.json()) @api_route("POST", "/llm-providers/config") - async def create( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/llm-providers/config", json_body=drop_none(body)) + async def create(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = await self._transport.request("POST", "/llm-providers/config", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) @api_route("GET", "/llm-providers/config/{integration_id}") @@ -267,26 +176,10 @@ async def get(self, *, integration_id: str) -> LLMProviderConfig: return LLMProviderConfig.model_validate(response.json()) @api_route("PUT", "/llm-providers/config/{integration_id}") - async def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - model_name: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = drop_none( - { - "integration_name": integration_name, - "provider": provider, - "model_name": model_name, - "base_url": base_url, - } + async def update(self, request: LLMProviderUpdateRequest, *, integration_id: str) -> LLMIntegrationResult: + response = await self._transport.request( + "PUT", f"/llm-providers/config/{integration_id}", json_body=request.to_wire() ) - body["api_key"] = api_key - response = await self._transport.request("PUT", f"/llm-providers/config/{integration_id}", json_body=body) return LLMIntegrationResult.model_validate(response.json()) @api_route("DELETE", "/llm-providers/config/{integration_id}") @@ -299,15 +192,6 @@ async def set_default(self, *, integration_id: str) -> LLMIntegrationResult: return LLMIntegrationResult.model_validate(response.json()) @api_route("POST", "/llm-providers/test-connection") - async def test_connection( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/llm-providers/test-connection", json_body=drop_none(body)) + async def test_connection(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = await self._transport.request("POST", "/llm-providers/test-connection", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) diff --git a/packages/discolike/tests/test_providers.py b/packages/discolike/tests/test_providers.py index ff1ddd9..32b426a 100644 --- a/packages/discolike/tests/test_providers.py +++ b/packages/discolike/tests/test_providers.py @@ -3,8 +3,12 @@ import json import httpx2 +import pydantic import pytest +from discolike.requests import LLMProviderCreateRequest +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import SearchProviderRequest from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -36,10 +40,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.search_providers.create( - integration_name="Tavily", - provider="tavily", - search_model="tavily/search", - api_key="tvly-key", + SearchProviderRequest( + integration_name="Tavily", provider="tavily", search_model="tavily/search", api_key="tvly-key" + ) ) assert seen["path"] == "/v1/search-providers" @@ -64,10 +67,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.search_providers.update( + SearchProviderRequest(integration_name="Serper", provider="serper", search_model="serper/search"), integration_id="sp3", - integration_name="Serper", - provider="serper", - search_model="serper/search", ) assert seen["path"] == "/v1/search-providers/sp3" @@ -172,10 +173,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.llm_providers.create( - integration_name="OpenAI", - provider="openai", - api_key="sk-key", - model_name="gpt-4o", + LLMProviderCreateRequest( + integration_name="OpenAI", provider="openai", api_key="sk-key", model_name="gpt-4o" + ) ) assert seen["path"] == "/v1/llm-providers/config" @@ -216,10 +216,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.llm_providers.update( + LLMProviderUpdateRequest( + integration_name="Anthropic", provider="anthropic", model_name="claude-sonnet-4-5", api_key=None + ), integration_id="llm3", - integration_name="Anthropic", - provider="anthropic", - model_name="claude-sonnet-4-5", ) assert seen["path"] == "/v1/llm-providers/config/llm3" @@ -242,11 +242,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: client.llm_providers.update( + LLMProviderUpdateRequest( + integration_name="Anthropic", provider="anthropic", model_name="claude-sonnet-4-5", api_key="sk-new" + ), integration_id="llm3", - integration_name="Anthropic", - provider="anthropic", - model_name="claude-sonnet-4-5", - api_key="sk-new", ) assert seen["body"]["api_key"] == "sk-new" @@ -295,11 +294,13 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.llm_providers.test_connection( - integration_name="probe", - provider="openai", - api_key="sk-key", - model_name="gpt-4o", - base_url="https://proxy.example.com", + LLMProviderCreateRequest( + integration_name="probe", + provider="openai", + api_key="sk-key", + model_name="gpt-4o", + base_url="https://proxy.example.com", + ) ) assert seen["path"] == "/v1/llm-providers/test-connection" @@ -353,10 +354,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: result = await client.llm_providers.create( - integration_name="OpenAI", - provider="openai", - api_key="sk-key", - model_name="gpt-4o", + LLMProviderCreateRequest( + integration_name="OpenAI", provider="openai", api_key="sk-key", model_name="gpt-4o" + ) ) assert seen["path"] == "/v1/llm-providers/config" @@ -374,15 +374,22 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: await client.llm_providers.update( + LLMProviderUpdateRequest( + integration_name="Anthropic", provider="anthropic", model_name="claude-sonnet-4-5", api_key=None + ), integration_id="llm7", - integration_name="Anthropic", - provider="anthropic", - model_name="claude-sonnet-4-5", ) assert seen["body"]["api_key"] is None +def test_llm_update_requires_api_key_to_be_passed_even_when_none() -> None: + with pytest.raises(pydantic.ValidationError, match="api_key"): + LLMProviderUpdateRequest.model_validate( + {"integration_name": "Anthropic", "provider": "anthropic", "model_name": "claude-sonnet-4-5"} + ) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.providers import LLMProvidersResource From d505d06d0b3dff071390613e5e7d6ffe0fe4fe66 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:04:03 -0700 Subject: [PATCH 12/28] feat(sdk)!: discogen and validate_icp take generated request models Domain/persona list bounds (1-10000), context_mode and search_context_size enums are checked before the job is submitted. --- packages/discolike/src/discolike/_client.py | 41 +------- .../src/discolike/resources/discogen.py | 96 +++---------------- packages/discolike/tests/test_discogen.py | 64 ++++++++----- 3 files changed, 59 insertions(+), 142 deletions(-) diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index 483c6e7..66f1205 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -11,6 +11,7 @@ from discolike._jobs import Job from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike.requests import ValidateIcpRequest from discolike.resources.account import AccountResource from discolike.resources.account import AsyncAccountResource from discolike.resources.companies import AsyncCompaniesResource @@ -230,24 +231,8 @@ def count( exclude_leadgen=exclude_leadgen, ) - def validate_icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> Job: - return self._validate.icp( - icp_text=icp_text, - domains=domains, - context_mode=context_mode, - integration_id=integration_id, - web_search=web_search, - search_provider_id=search_provider_id, - ) + def validate_icp(self, request: ValidateIcpRequest) -> Job: + return self._validate.icp(request) def append( self, @@ -480,24 +465,8 @@ async def count( exclude_leadgen=exclude_leadgen, ) - async def validate_icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> AsyncJob: - return await self._validate.icp( - icp_text=icp_text, - domains=domains, - context_mode=context_mode, - integration_id=integration_id, - web_search=web_search, - search_provider_id=search_provider_id, - ) + async def validate_icp(self, request: ValidateIcpRequest) -> AsyncJob: + return await self._validate.icp(request) async def append( self, diff --git a/packages/discolike/src/discolike/resources/discogen.py b/packages/discolike/src/discolike/resources/discogen.py index 71a9f25..dae85f6 100644 --- a/packages/discolike/src/discolike/resources/discogen.py +++ b/packages/discolike/src/discolike/resources/discogen.py @@ -6,7 +6,9 @@ from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import DiscoGenPersonaProcessRequest +from discolike.requests import DiscoGenProcessRequest +from discolike.requests import ValidateIcpRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -23,37 +25,13 @@ class DiscogenModels(DiscolikeModel): class DiscogenResource(SyncAPIResource): @api_route("POST", "/discogen/process") - def process( - self, - *, - query: str, - domains: list[str], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/discogen/process", json_body=drop_none(body)) + def process(self, request: DiscoGenProcessRequest) -> Job: + response = self._transport.request("POST", "/discogen/process", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("POST", "/discogen/process-personas") - def process_personas( - self, - *, - query: str, - persona_ids: list[int], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/discogen/process-personas", json_body=drop_none(body)) + def process_personas(self, request: DiscoGenPersonaProcessRequest) -> Job: + response = self._transport.request("POST", "/discogen/process-personas", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("GET", "/discogen/models") @@ -66,54 +44,20 @@ def job(self, task_id: str) -> Job: class ValidateResource(SyncAPIResource): @api_route("POST", "/validate/icp") - def icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/validate/icp", json_body=drop_none(body)) + def icp(self, request: ValidateIcpRequest) -> Job: + response = self._transport.request("POST", "/validate/icp", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) class AsyncDiscogenResource(AsyncAPIResource): @api_route("POST", "/discogen/process") - async def process( - self, - *, - query: str, - domains: list[str], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/discogen/process", json_body=drop_none(body)) + async def process(self, request: DiscoGenProcessRequest) -> AsyncJob: + response = await self._transport.request("POST", "/discogen/process", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("POST", "/discogen/process-personas") - async def process_personas( - self, - *, - query: str, - persona_ids: list[int], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/discogen/process-personas", json_body=drop_none(body)) + async def process_personas(self, request: DiscoGenPersonaProcessRequest) -> AsyncJob: + response = await self._transport.request("POST", "/discogen/process-personas", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("GET", "/discogen/models") @@ -127,16 +71,6 @@ def job(self, task_id: str) -> AsyncJob: class AsyncValidateResource(AsyncAPIResource): @api_route("POST", "/validate/icp") - async def icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/validate/icp", json_body=drop_none(body)) + async def icp(self, request: ValidateIcpRequest) -> AsyncJob: + response = await self._transport.request("POST", "/validate/icp", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) diff --git a/packages/discolike/tests/test_discogen.py b/packages/discolike/tests/test_discogen.py index 8c699cf..67e0bab 100644 --- a/packages/discolike/tests/test_discogen.py +++ b/packages/discolike/tests/test_discogen.py @@ -3,11 +3,15 @@ import json import httpx2 +import pydantic import pytest from discolike._jobs import FAMILY_DISCOGEN from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import DiscoGenPersonaProcessRequest +from discolike.requests import DiscoGenProcessRequest +from discolike.requests import ValidateIcpRequest from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -23,9 +27,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.discogen.process( - query="Recent funding rounds", - domains=["acme.com", "globex.com"], - web_search=True, + DiscoGenProcessRequest(query="Recent funding rounds", domains=["acme.com", "globex.com"], web_search=True) ) assert seen["path"] == "/v1/discogen/process" @@ -48,7 +50,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-2"}) with make_client(handler) as client: - client.discogen.process(query="q", domains=["a.com"]) + client.discogen.process(DiscoGenProcessRequest(query="q", domains=["a.com"])) assert seen["body"] == {"query": "q", "domains": ["a.com"]} @@ -62,14 +64,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: client.discogen.process( - query="q", - domains=["a.com"], - integration_id="int-1", - web_search=True, - context_mode="website", - include_x_search=False, - search_provider_id="serper", - search_context_size="medium", + DiscoGenProcessRequest( + query="q", + domains=["a.com"], + integration_id="int-1", + web_search=True, + context_mode="website", + include_x_search=False, + search_provider_id="serper", + search_context_size="medium", + ) ) assert seen["body"] == { @@ -95,8 +99,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.discogen.process_personas( - query="Recent job changes", - persona_ids=[111, 222], + DiscoGenPersonaProcessRequest(query="Recent job changes", persona_ids=[111, 222]) ) assert seen["path"] == "/v1/discogen/process-personas" @@ -156,8 +159,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.validate_icp( - icp_text="VPs of Marketing at B2B SaaS", - domains=["gusto.com", "rippling.com"], + ValidateIcpRequest(icp_text="VPs of Marketing at B2B SaaS", domains=["gusto.com", "rippling.com"]) ) assert seen["path"] == "/v1/validate/icp" @@ -180,12 +182,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: client.validate_icp( - icp_text="q", - domains=["a.com"], - context_mode="website", - integration_id="int-1", - web_search=True, - search_provider_id="serper", + ValidateIcpRequest( + icp_text="q", + domains=["a.com"], + context_mode="website", + integration_id="int-1", + web_search=True, + search_provider_id="serper", + ) ) assert seen["body"] == { @@ -203,7 +207,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-async-1"}) async with make_async_client(handler) as client: - job = await client.discogen.process(query="q", domains=["a.com"]) + job = await client.discogen.process(DiscoGenProcessRequest(query="q", domains=["a.com"])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN @@ -215,7 +219,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-async-2"}) async with make_async_client(handler) as client: - job = await client.discogen.process_personas(query="q", persona_ids=[1]) + job = await client.discogen.process_personas(DiscoGenPersonaProcessRequest(query="q", persona_ids=[1])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN @@ -249,13 +253,23 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "val-async-1"}) async with make_async_client(handler) as client: - job = await client.validate_icp(icp_text="q", domains=["a.com"]) + job = await client.validate_icp(ValidateIcpRequest(icp_text="q", domains=["a.com"])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN assert job.task_id == "val-async-1" +def test_process_rejects_empty_domains_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="domains"): + DiscoGenProcessRequest(query="q", domains=[]) + + +def test_process_rejects_unknown_context_mode_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="context_mode"): + DiscoGenProcessRequest.model_validate({"query": "q", "domains": ["a.com"], "context_mode": "bogus"}) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.discogen import DiscogenResource From a746741c068ef5106a348266845e197d151c2e49 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:05:31 -0700 Subject: [PATCH 13/28] feat(sdk)!: discover/count take generated request models The 41-parameter signature was copied three times (resource, sync client, async client); DiscoverParams is generated from the spec, so new filters no longer need four hand edits. --- packages/discolike/src/discolike/_client.py | 298 +----------------- .../src/discolike/resources/discovery.py | 166 +--------- packages/discolike/tests/test_discovery.py | 39 ++- 3 files changed, 56 insertions(+), 447 deletions(-) diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index 66f1205..aab85db 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -11,6 +11,8 @@ from discolike._jobs import Job from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike.requests import CountParams +from discolike.requests import DiscoverParams from discolike.requests import ValidateIcpRequest from discolike.resources.account import AccountResource from discolike.resources.account import AsyncAccountResource @@ -85,151 +87,11 @@ def with_options(self, *, timeout: float | httpx2.Timeout) -> Discolike: clone._attach(self._transport.with_timeout(timeout)) return clone - def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - return self._discovery.discover( - domain=domain, - exclude_domain=exclude_domain, - icp_text=icp_text, - icp_prompt=icp_prompt, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - business_model=business_model, - negate_business_model=negate_business_model, - employee_range=employee_range, - revenue_range=revenue_range, - start_date=start_date, - redirect=redirect, - exclude_leadgen=exclude_leadgen, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - min_similarity=min_similarity, - consensus=consensus, - variance=variance, - retrieval=retrieval, - enhanced=enhanced, - include_search_domains=include_search_domains, - auto_icp_text=auto_icp_text, - auto_phrase_match=auto_phrase_match, - max_records=max_records, - offset=offset, - exclusion_query_id=exclusion_query_id, - inclusion_query_id=inclusion_query_id, - ) + def discover(self, params: DiscoverParams) -> list[Company]: + return self._discovery.discover(params) - def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - return self._discovery.count( - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - start_date=start_date, - redirect=redirect, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - exclude_leadgen=exclude_leadgen, - ) + def count(self, params: CountParams) -> Count: + return self._discovery.count(params) def validate_icp(self, request: ValidateIcpRequest) -> Job: return self._validate.icp(request) @@ -319,151 +181,11 @@ def with_options(self, *, timeout: float | httpx2.Timeout) -> AsyncDiscolike: clone._attach(self._transport.with_timeout(timeout)) return clone - async def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - return await self._discovery.discover( - domain=domain, - exclude_domain=exclude_domain, - icp_text=icp_text, - icp_prompt=icp_prompt, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - business_model=business_model, - negate_business_model=negate_business_model, - employee_range=employee_range, - revenue_range=revenue_range, - start_date=start_date, - redirect=redirect, - exclude_leadgen=exclude_leadgen, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - min_similarity=min_similarity, - consensus=consensus, - variance=variance, - retrieval=retrieval, - enhanced=enhanced, - include_search_domains=include_search_domains, - auto_icp_text=auto_icp_text, - auto_phrase_match=auto_phrase_match, - max_records=max_records, - offset=offset, - exclusion_query_id=exclusion_query_id, - inclusion_query_id=inclusion_query_id, - ) + async def discover(self, params: DiscoverParams) -> list[Company]: + return await self._discovery.discover(params) - async def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - return await self._discovery.count( - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - start_date=start_date, - redirect=redirect, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - exclude_leadgen=exclude_leadgen, - ) + async def count(self, params: CountParams) -> Count: + return await self._discovery.count(params) async def validate_icp(self, request: ValidateIcpRequest) -> AsyncJob: return await self._validate.icp(request) diff --git a/packages/discolike/src/discolike/resources/discovery.py b/packages/discolike/src/discolike/resources/discovery.py index f77c688..395c5a7 100644 --- a/packages/discolike/src/discolike/resources/discovery.py +++ b/packages/discolike/src/discolike/resources/discovery.py @@ -1,6 +1,8 @@ from __future__ import annotations from discolike._models import DiscolikeModel +from discolike.requests import CountParams +from discolike.requests import DiscoverParams from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -17,170 +19,22 @@ class Count(DiscolikeModel): class DiscoveryResource(SyncAPIResource): @api_route("GET", "/discover") - def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - params = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("GET", "/discover", params=params) + def discover(self, params: DiscoverParams) -> list[Company]: + response = self._transport.request("GET", "/discover", params=params.to_wire()) return [Company.model_validate(item) for item in response.json()] @api_route("GET", "/count") - def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - return Count.model_validate(self._transport.request("GET", "/count", params=params).json()) + def count(self, params: CountParams) -> Count: + return Count.model_validate(self._transport.request("GET", "/count", params=params.to_wire()).json()) class AsyncDiscoveryResource(AsyncAPIResource): @api_route("GET", "/discover") - async def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/discover", params=params) + async def discover(self, params: DiscoverParams) -> list[Company]: + response = await self._transport.request("GET", "/discover", params=params.to_wire()) return [Company.model_validate(item) for item in response.json()] @api_route("GET", "/count") - async def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/count", params=params) + async def count(self, params: CountParams) -> Count: + response = await self._transport.request("GET", "/count", params=params.to_wire()) return Count.model_validate(response.json()) diff --git a/packages/discolike/tests/test_discovery.py b/packages/discolike/tests/test_discovery.py index 208a252..a7d2309 100644 --- a/packages/discolike/tests/test_discovery.py +++ b/packages/discolike/tests/test_discovery.py @@ -1,7 +1,11 @@ from __future__ import annotations import httpx2 +import pydantic +import pytest +from discolike.requests import CountParams +from discolike.requests import DiscoverParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -18,24 +22,44 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) with make_client(handler) as client: - results = client.discover(icp_prompt="B2B fintech in DE", country=["DE", "AT"], max_records=50) + results = client.discover(DiscoverParams(icp_prompt="B2B fintech in DE", country=["DE", "AT"], max_records=50)) assert seen["path"] == "/v1/discover" assert seen["params"].get_list("country") == ["DE", "AT"] assert seen["params"]["max_records"] == "50" assert "domain" not in dict(seen["params"]) + assert "offset" not in dict(seen["params"]) assert results[0].domain == "acme.com" assert results[0].similarity == 87.3 assert results[0].model_extra["extra_field"] == "kept" # ty: ignore[not-subscriptable] +def test_discover_passes_unknown_fields_through(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = httpx2.QueryParams(request.url.query) + return httpx2.Response(200, json=[]) + + with make_client(handler) as client: + client.discover(DiscoverParams.model_validate({"future_flag": "on"})) + + assert seen["params"]["future_flag"] == "on" + + +def test_discover_rejects_out_of_range_similarity_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="min_similarity"): + DiscoverParams(min_similarity=200) + + def test_count(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: assert request.url.path == "/v1/count" + assert httpx2.QueryParams(request.url.query)["category"] == "CYBERSECURITY" return httpx2.Response(200, json={"count": 1234}) with make_client(handler) as client: - result = client.count(category=["CYBERSECURITY"]) + result = client.count(CountParams(category=["CYBERSECURITY"])) assert result.count == 1234 @@ -44,5 +68,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"domain": "a.com"}]) async with make_async_client(handler) as client: - results = await client.discover(domain=["stripe.com"]) + results = await client.discover(DiscoverParams(domain=["stripe.com"])) assert results[0].domain == "a.com" + + +async def test_count_async(make_async_client: AsyncClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"count": 7}) + + async with make_async_client(handler) as client: + result = await client.count(CountParams(country=["US"])) + assert result.count == 7 From eb4331c6b81f19b875cea92da17b5ef48bced324 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:08:59 -0700 Subject: [PATCH 14/28] feat(sdk)!: append/segment take generated request models; add segment_file GET /segment and POST /segment are different routes with different params; one wrapper over both is why ignore_params existed. Each gets its own method and model. domains is a comma-separated string, as the spec declares it, instead of a list the SDK joined behind the caller's back. --- packages/discolike/src/discolike/_client.py | 85 +++-------- .../src/discolike/resources/enrich.py | 136 +++++------------- packages/discolike/tests/test_enrich.py | 102 +++++++++---- 3 files changed, 125 insertions(+), 198 deletions(-) diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index aab85db..623455e 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -1,8 +1,5 @@ from __future__ import annotations -import pathlib -from typing import BinaryIO - import httpx2 from discolike._config import DEFAULT_BASE_URL @@ -11,9 +8,13 @@ from discolike._jobs import Job from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike.requests import AppendParams from discolike.requests import CountParams from discolike.requests import DiscoverParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams from discolike.requests import ValidateIcpRequest +from discolike.resources._base import FileInput from discolike.resources.account import AccountResource from discolike.resources.account import AsyncAccountResource from discolike.resources.companies import AsyncCompaniesResource @@ -96,39 +97,14 @@ def count(self, params: CountParams) -> Count: def validate_icp(self, request: ValidateIcpRequest) -> Job: return self._validate.icp(request) - def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - return self._enrich.append( - file=file, - dataset=dataset, - domain_column=domain_column, - csv=csv, - query_id=query_id, - ) + def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + return self._enrich.append(params, file=file) - def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> Job: - return self._enrich.segment( - domains=domains, - file=file, - domain_column=domain_column, - max_segments=max_segments, - query_id=query_id, - ) + def segment(self, params: SegmentParams) -> Job: + return self._enrich.segment(params) + + def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> Job: + return self._enrich.segment_file(params, file=file) def close(self) -> None: self._transport.close() @@ -190,39 +166,14 @@ async def count(self, params: CountParams) -> Count: async def validate_icp(self, request: ValidateIcpRequest) -> AsyncJob: return await self._validate.icp(request) - async def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - return await self._enrich.append( - file=file, - dataset=dataset, - domain_column=domain_column, - csv=csv, - query_id=query_id, - ) + async def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + return await self._enrich.append(params, file=file) - async def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> AsyncJob: - return await self._enrich.segment( - domains=domains, - file=file, - domain_column=domain_column, - max_segments=max_segments, - query_id=query_id, - ) + async def segment(self, params: SegmentParams) -> AsyncJob: + return await self._enrich.segment(params) + + async def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> AsyncJob: + return await self._enrich.segment_file(params, file=file) async def aclose(self) -> None: await self._transport.aclose() diff --git a/packages/discolike/src/discolike/resources/enrich.py b/packages/discolike/src/discolike/resources/enrich.py index b1ef2d0..6495a0e 100644 --- a/packages/discolike/src/discolike/resources/enrich.py +++ b/packages/discolike/src/discolike/resources/enrich.py @@ -1,14 +1,14 @@ from __future__ import annotations -import pathlib -from typing import Any -from typing import BinaryIO - from discolike._jobs import FAMILY_SEGMENT from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel +from discolike.requests import AppendParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams from discolike.resources._base import AsyncAPIResource +from discolike.resources._base import FileInput from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route from discolike.resources._base import open_upload @@ -22,24 +22,17 @@ class AppendResult(DiscolikeModel): class EnrichResource(SyncAPIResource): @api_route("POST", "/append") - def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - if file is None and query_id is None: + def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + if file is None and not params.query_id: raise ValueError("one of file or query_id is required") - params = {"dataset": dataset, "domain_column": domain_column, "csv": csv, "query_id": query_id} if file is None: - response = self._transport.request("POST", "/append", params=params) + response = self._transport.request("POST", "/append", params=params.to_wire()) else: filename, fh, we_opened_it = open_upload(file) try: - response = self._transport.request("POST", "/append", params=params, files={"file": (filename, fh)}) + response = self._transport.request( + "POST", "/append", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() @@ -47,71 +40,38 @@ def append( return [AppendResult.model_validate(item) for item in response.json()] return response.content + @api_route("GET", "/segment") + def segment(self, params: SegmentParams) -> Job: + if not params.domains and not params.query_id: + raise ValueError("one of domains or query_id is required") + response = self._transport.request("GET", "/segment", params=params.to_wire()) + return Job(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) + @api_route("POST", "/segment") - def _segment_file( - self, - *, - file: pathlib.Path | str | BinaryIO, - domain_column: str | None, - max_segments: int | None, - ) -> Job: - params = {"domain_column": domain_column, "max_segments": max_segments} + def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> Job: filename, fh, we_opened_it = open_upload(file) try: - response = self._transport.request("POST", "/segment", params=params, files={"file": (filename, fh)}) + response = self._transport.request( + "POST", "/segment", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() return Job(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - @api_route("GET", "/segment") - def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> Job: - if file is not None: - if domains is not None or query_id is not None: - raise ValueError("file cannot be combined with domains or query_id") - return self._segment_file(file=file, domain_column=domain_column, max_segments=max_segments) - if domains is None and query_id is None: - raise ValueError("one of domains, query_id, or file is required") - if domain_column is not None: - raise ValueError("domain_column only applies to file uploads") - params: dict[str, Any] = {"max_segments": max_segments} - if domains is not None: - params["domains"] = ",".join(domains) - if query_id is not None: - params["query_id"] = ",".join(query_id) - response = self._transport.request("GET", "/segment", params=params) - return Job(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - class AsyncEnrichResource(AsyncAPIResource): @api_route("POST", "/append") - async def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - if file is None and query_id is None: + async def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + if file is None and not params.query_id: raise ValueError("one of file or query_id is required") - params = {"dataset": dataset, "domain_column": domain_column, "csv": csv, "query_id": query_id} if file is None: - response = await self._transport.request("POST", "/append", params=params) + response = await self._transport.request("POST", "/append", params=params.to_wire()) else: filename, fh, we_opened_it = open_upload(file) try: response = await self._transport.request( - "POST", "/append", params=params, files={"file": (filename, fh)} + "POST", "/append", params=params.to_wire(), files={"file": (filename, fh)} ) finally: if we_opened_it: @@ -120,45 +80,21 @@ async def append( return [AppendResult.model_validate(item) for item in response.json()] return response.content + @api_route("GET", "/segment") + async def segment(self, params: SegmentParams) -> AsyncJob: + if not params.domains and not params.query_id: + raise ValueError("one of domains or query_id is required") + response = await self._transport.request("GET", "/segment", params=params.to_wire()) + return AsyncJob(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) + @api_route("POST", "/segment") - async def _segment_file( - self, - *, - file: pathlib.Path | str | BinaryIO, - domain_column: str | None, - max_segments: int | None, - ) -> AsyncJob: - params = {"domain_column": domain_column, "max_segments": max_segments} + async def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> AsyncJob: filename, fh, we_opened_it = open_upload(file) try: - response = await self._transport.request("POST", "/segment", params=params, files={"file": (filename, fh)}) + response = await self._transport.request( + "POST", "/segment", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() return AsyncJob(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - - @api_route("GET", "/segment") - async def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> AsyncJob: - if file is not None: - if domains is not None or query_id is not None: - raise ValueError("file cannot be combined with domains or query_id") - return await self._segment_file(file=file, domain_column=domain_column, max_segments=max_segments) - if domains is None and query_id is None: - raise ValueError("one of domains, query_id, or file is required") - if domain_column is not None: - raise ValueError("domain_column only applies to file uploads") - params: dict[str, Any] = {"max_segments": max_segments} - if domains is not None: - params["domains"] = ",".join(domains) - if query_id is not None: - params["query_id"] = ",".join(query_id) - response = await self._transport.request("GET", "/segment", params=params) - return AsyncJob(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) diff --git a/packages/discolike/tests/test_enrich.py b/packages/discolike/tests/test_enrich.py index 1264d68..bf92e6c 100644 --- a/packages/discolike/tests/test_enrich.py +++ b/packages/discolike/tests/test_enrich.py @@ -3,11 +3,15 @@ import io import httpx2 +import pydantic import pytest from discolike._jobs import FAMILY_SEGMENT from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import AppendParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -15,13 +19,16 @@ def test_append_json_response_parses_result_list(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: assert request.url.path == "/v1/append" - params = dict(httpx2.QueryParams(request.url.query)) + params = httpx2.QueryParams(request.url.query) assert params["domain_column"] == "website" + assert params.get_list("dataset") == ["bizdata"] assert b"Acme" in request.content return httpx2.Response(200, json=[{"domain": "acme.com", "name": "Acme", "extra_field": "kept"}]) with make_client(handler) as client: - result = client._enrich.append(file=io.BytesIO(b"website\nAcme\n"), domain_column="website") + result = client._enrich.append( + AppendParams(dataset=["bizdata"], domain_column="website"), file=io.BytesIO(b"website\nAcme\n") + ) assert isinstance(result, list) assert result[0].domain == "acme.com" @@ -33,7 +40,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=b"col1,col2\n", headers={"Content-Type": "text/csv"}) with make_client(handler) as client: - result = client.append(file=io.BytesIO(b"domain\nacme.com\n"), csv=True) + result = client.append(AppendParams(dataset=["bizdata"], csv=True), file=io.BytesIO(b"domain\nacme.com\n")) assert result == b"col1,col2\n" @@ -48,34 +55,64 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[]) with make_client(handler) as client: - client.append(file=csv_path, dataset=["bizdata", "growth"]) + client.append(AppendParams(dataset=["bizdata", "growth"]), file=csv_path) assert seen["params"].get_list("dataset") == ["bizdata", "growth"] +def test_append_without_file_uses_query_id_only(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = httpx2.QueryParams(request.url.query) + seen["content"] = request.content + return httpx2.Response(200, json=[]) + + with make_client(handler) as client: + client.append(AppendParams(dataset=["bizdata"], query_id=["q1"])) + + assert seen["params"]["query_id"] == "q1" + assert seen["content"] == b"" + + +def test_append_raises_when_neither_file_nor_query_id_given(make_client: ClientFactory) -> None: + with ( + make_client(lambda request: httpx2.Response(200, json=[])) as client, + pytest.raises(ValueError, match="one of file or query_id is required"), + ): + client.append(AppendParams(dataset=["bizdata"])) + + +def test_append_rejects_unknown_dataset_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="dataset"): + AppendParams.model_validate({"dataset": ["bogus"]}) + + async def test_append_async_json_response(make_async_client: AsyncClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"domain": "acme.com"}]) async with make_async_client(handler) as client: - result = await client.append(file=io.BytesIO(b"domain\nacme.com\n")) + result = await client.append(AppendParams(dataset=["bizdata"]), file=io.BytesIO(b"domain\nacme.com\n")) assert isinstance(result, list) assert result[0].domain == "acme.com" -def test_segment_domains_branch_comma_joins_and_returns_job(make_client: ClientFactory) -> None: +def test_segment_sends_comma_separated_domains_and_returns_job(make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: seen["path"] = request.url.path + seen["method"] = request.method seen["params"] = httpx2.QueryParams(request.url.query) return httpx2.Response(200, json={"task_id": "seg-1"}) with make_client(handler) as client: - job = client.segment(domains=["acme.com", "beta.com"], max_segments=5) + job = client.segment(SegmentParams(domains="acme.com,beta.com", max_segments=5)) assert seen["path"] == "/v1/segment" + assert seen["method"] == "GET" assert seen["params"]["domains"] == "acme.com,beta.com" assert seen["params"]["max_segments"] == "5" assert isinstance(job, Job) @@ -83,7 +120,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert job.task_id == "seg-1" -def test_segment_file_branch_returns_job(tmp_path, make_client: ClientFactory) -> None: +def test_segment_file_posts_upload_and_returns_job(tmp_path, make_client: ClientFactory) -> None: csv_path = tmp_path / "domains.csv" csv_path.write_text("domain\nacme.com\n") seen = {} @@ -91,55 +128,58 @@ def test_segment_file_branch_returns_job(tmp_path, make_client: ClientFactory) - def handler(request: httpx2.Request) -> httpx2.Response: seen["path"] = request.url.path seen["method"] = request.method + seen["params"] = httpx2.QueryParams(request.url.query) seen["content"] = request.content return httpx2.Response(200, json={"task_id": "seg-2"}) with make_client(handler) as client: - job = client.segment(file=csv_path, domain_column="domain") + job = client.segment_file(SegmentFileParams(domain_column="domain"), file=csv_path) assert seen["path"] == "/v1/segment" assert seen["method"] == "POST" + assert seen["params"]["domain_column"] == "domain" assert b"acme.com" in seen["content"] assert isinstance(job, Job) assert job.task_id == "seg-2" -def test_segment_raises_when_neither_domains_nor_file_given(make_client: ClientFactory) -> None: +def test_segment_raises_when_neither_domains_nor_query_id_given(make_client: ClientFactory) -> None: with ( make_client(lambda request: httpx2.Response(200, json={})) as client, - pytest.raises(ValueError, match="one of domains, query_id, or file is required"), + pytest.raises(ValueError, match="one of domains or query_id is required"), ): - client.segment() + client.segment(SegmentParams()) -def test_segment_raises_when_both_domains_and_file_given(tmp_path, make_client: ClientFactory) -> None: - csv_path = tmp_path / "domains.csv" - csv_path.write_text("domain\nacme.com\n") +def test_segment_rejects_max_segments_out_of_range_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="max_segments"): + SegmentParams(domains="acme.com", max_segments=99) - with ( - make_client(lambda request: httpx2.Response(200, json={})) as client, - pytest.raises(ValueError, match="file cannot be combined with domains or query_id"), - ): - client.segment(domains=["acme.com"], file=csv_path) +async def test_segment_async(make_async_client: AsyncClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"task_id": "seg-3"}) -def test_segment_raises_when_domain_column_given_with_domains(make_client: ClientFactory) -> None: - with ( - make_client(lambda request: httpx2.Response(200, json={})) as client, - pytest.raises(ValueError, match="domain_column only applies to file uploads"), - ): - client.segment(domains=["acme.com"], domain_column="domain") + async with make_async_client(handler) as client: + job = await client.segment(SegmentParams(domains="acme.com")) + assert isinstance(job, AsyncJob) + assert job.task_id == "seg-3" + + +async def test_segment_file_async(tmp_path, make_async_client: AsyncClientFactory) -> None: + csv_path = tmp_path / "domains.csv" + csv_path.write_text("domain\nacme.com\n") -async def test_segment_async_domains_branch(make_async_client: AsyncClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"task_id": "seg-3"}) + assert request.method == "POST" + return httpx2.Response(200, json={"task_id": "seg-4"}) async with make_async_client(handler) as client: - job = await client.segment(domains=["acme.com"]) + job = await client.segment_file(SegmentFileParams(), file=csv_path) assert isinstance(job, AsyncJob) - assert job.task_id == "seg-3" + assert job.task_id == "seg-4" def test_route_metadata_stamped() -> None: @@ -148,4 +188,4 @@ def test_route_metadata_stamped() -> None: assert get_discolike_route(EnrichResource.append) == ("POST", "/append", True) assert get_discolike_route(EnrichResource.segment) == ("GET", "/segment", True) - assert get_discolike_route(EnrichResource._segment_file) == ("POST", "/segment", True) + assert get_discolike_route(EnrichResource.segment_file) == ("POST", "/segment", True) From 6d8436fa1829e42cbde33013e1619c515d1e45e3 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:13:23 -0700 Subject: [PATCH 15/28] ci: check request models against the spec in both directions check_contract.py reads each method's request model off its annotation and diffs the field set against the spec's query params and body properties, so a platform field the SDK never declared fails the run just like an SDK field the platform dropped. gen_requests.py --check catches the generated file going stale on the same schedule. Regenerating for that new gate reorders two classes in requests.py; the line multiset is unchanged. --- .github/workflows/contract.yml | 2 + .../src/discolike/_generated/requests.py | 52 ++++---- .../discolike/tests/test_contract_registry.py | 113 ++++++++++++++++++ scripts/check_contract.py | 65 +++++++--- 4 files changed, 188 insertions(+), 44 deletions(-) diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 9a6eefb..04eb4dc 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -27,6 +27,8 @@ jobs: run: | if [ "$BASE_REF" = "development" ] && [ -n "$DEV_SPEC_URL" ]; then uv run python scripts/check_contract.py --spec-url "$DEV_SPEC_URL" + uv run python scripts/gen_requests.py --check --spec-url "$DEV_SPEC_URL" else uv run python scripts/check_contract.py + uv run python scripts/gen_requests.py --check fi diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 107d326..a0458f1 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2588,32 +2588,6 @@ class AppendParams(DiscolikeRequest): ] = None -class SegmentFileParams(DiscolikeRequest): - domain_column: Annotated[ - str | None, - Field( - description="Column name containing domains (auto-detected if not provided).", - title="Domain Column", - ), - ] = "domain" - max_segments: Annotated[ - int | None, - Field( - description="Maximum number of segments to create (2-100).", - ge=2, - le=100, - title="Max Segments", - ), - ] = None - query_id: Annotated[ - list[str] | None, - Field( - description="Include domains from these saved queries in the segmentation.", - title="Query Id", - ), - ] = None - - class SegmentParams(DiscolikeRequest): domains: Annotated[ str | None, @@ -2640,6 +2614,32 @@ class SegmentParams(DiscolikeRequest): ] = None +class SegmentFileParams(DiscolikeRequest): + domain_column: Annotated[ + str | None, + Field( + description="Column name containing domains (auto-detected if not provided).", + title="Domain Column", + ), + ] = "domain" + max_segments: Annotated[ + int | None, + Field( + description="Maximum number of segments to create (2-100).", + ge=2, + le=100, + title="Max Segments", + ), + ] = None + query_id: Annotated[ + list[str] | None, + Field( + description="Include domains from these saved queries in the segmentation.", + title="Query Id", + ), + ] = None + + class MatchCompanyParams(DiscolikeRequest): name: Annotated[str, Field(description="The company name to match", title="Name")] phone: Annotated[ diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 9f21171..00fca89 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -98,3 +98,116 @@ def test_mirrored_schemas_cover_the_shared_company_profile(): from discolike.resources.companies import CompanyProfile assert check_contract.MIRRORED_SCHEMAS["CompanyResult"] is CompanyProfile + + +def _route(check_contract, class_name: str, method_name: str): + return [ + route + for route in check_contract.collect_routes() + if route.class_name == class_name and route.method_name == method_name + ] + + +def _spec_with_params(path: str, names: list[str], *, deprecated: tuple[str, ...] = ()) -> dict: + parameters: list[dict[str, object]] = [{"name": name, "in": "query"} for name in names] + parameters.extend({"name": name, "in": "query", "deprecated": True} for name in deprecated) + return {"paths": {path: {"get": {"parameters": parameters}}}} + + +def test_collect_routes_reads_the_request_model_from_the_annotation(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + (route,) = _route(check_contract, "MatchResource", "company") + assert route.request_model is MatchCompanyParams + + +def test_collect_routes_leaves_request_model_none_for_bare_routes(): + check_contract = _load_check_contract() + (route,) = _route(check_contract, "AccountResource", "usage") + assert route.request_model is None + + +def test_check_passes_when_model_fields_match_spec_params(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + routes = _route(check_contract, "MatchResource", "company") + spec = _spec_with_params("/match", list(MatchCompanyParams.model_fields), deprecated=("nl_match",)) + assert check_contract.check(spec, routes) == [] + + +def test_check_reports_model_field_the_spec_lacks(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + routes = _route(check_contract, "MatchResource", "company") + names = [name for name in MatchCompanyParams.model_fields if name != "zip_code"] + mismatches = check_contract.check(_spec_with_params("/match", names), routes) + assert mismatches == [ + "MatchResource.company (GET /match): field 'zip_code' of MatchCompanyParams not found in spec" + ] + + +def test_check_reports_spec_param_the_model_lacks(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + routes = _route(check_contract, "MatchResource", "company") + names = [*MatchCompanyParams.model_fields, "brand_new"] + mismatches = check_contract.check(_spec_with_params("/match", names), routes) + assert mismatches == [ + "MatchResource.company (GET /match): spec param 'brand_new' not declared on MatchCompanyParams" + ] + + +def test_check_reports_params_on_a_route_without_a_model(): + check_contract = _load_check_contract() + routes = _route(check_contract, "AccountResource", "usage") + mismatches = check_contract.check(_spec_with_params("/usage", ["verbose"]), routes) + assert mismatches == [ + "AccountResource.usage (GET /usage): spec has param 'verbose' but the method takes no request model" + ] + + +def test_check_ignores_the_multipart_file_field(): + check_contract = _load_check_contract() + from discolike.requests import MatchBulkParams + + routes = _route(check_contract, "MatchResource", "bulk") + spec = { + "paths": { + "/bulkmatch": { + "post": { + "parameters": [{"name": name, "in": "query"} for name in MatchBulkParams.model_fields], + "requestBody": { + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_bulk_match"}}} + }, + } + } + }, + "components": {"schemas": {"Body_bulk_match": {"properties": {"file": {"type": "string"}}}}}, + } + assert check_contract.check(spec, routes) == [] + + +def test_check_compares_json_body_properties_bidirectionally(): + check_contract = _load_check_contract() + from discolike.requests import FindEmailRequest + + routes = _route(check_contract, "EmailResource", "find") + properties = {name: {} for name in FindEmailRequest.model_fields} + properties["legacy"] = {"deprecated": True} + spec = { + "paths": { + "/email/find": { + "post": { + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FindEmailRequest"}}} + } + } + } + }, + "components": {"schemas": {"FindEmailRequest": {"properties": properties}}}, + } + assert check_contract.check(spec, routes) == [] diff --git a/scripts/check_contract.py b/scripts/check_contract.py index 38f37af..4cf0d93 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -7,6 +7,7 @@ import pathlib import pkgutil import sys +import typing from dataclasses import dataclass from types import ModuleType @@ -14,6 +15,7 @@ import discolike.resources from discolike._models import DiscolikeModel +from discolike._models import DiscolikeRequest from discolike.resources._base import get_discolike_route from discolike.resources.companies import CompanyProfile from discolike.resources.companies import ExtractResult @@ -26,7 +28,9 @@ from discolike.resources.match import MatchResponse from discolike.resources.queries import SavedQueries -IGNORE_PARAMS = {"file", "contacts", "domain_column"} +IGNORE_PARAMS = {"file"} +QUERY_LOCATION = "query" +ASYNC_CLASS_PREFIX = "Async" # SDK response model -> the OpenAPI component schema it mirrors. Anything listed here is # checked field-by-field against the spec, so a platform-side model change surfaces as a @@ -45,7 +49,6 @@ } SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 -PATH_METHODS_WITH_BODY = {"POST", "PUT", "PATCH"} @dataclass(frozen=True) @@ -55,7 +58,7 @@ class RouteEntry: http_method: str path: str openapi: bool - params: tuple[str, ...] + request_model: type[DiscolikeRequest] | None def _resource_modules() -> list[ModuleType]: @@ -69,11 +72,18 @@ def _resource_modules() -> list[ModuleType]: return modules +def _request_model(member: object) -> type[DiscolikeRequest] | None: + for annotation in typing.get_type_hints(member).values(): + if inspect.isclass(annotation) and issubclass(annotation, DiscolikeRequest): + return annotation + return None + + def collect_routes() -> list[RouteEntry]: seen: dict[tuple[str, str], RouteEntry] = {} for module in _resource_modules(): for class_name, cls in inspect.getmembers(module, inspect.isclass): - if cls.__module__ != module.__name__: + if cls.__module__ != module.__name__ or class_name.startswith(ASYNC_CLASS_PREFIX): continue for method_name, member in vars(cls).items(): if not inspect.isfunction(member): @@ -85,13 +95,7 @@ def collect_routes() -> list[RouteEntry]: key = (http_method, path) if key in seen: continue - excluded = IGNORE_PARAMS - params = tuple( - name - for name, param in inspect.signature(member).parameters.items() - if param.kind is inspect.Parameter.KEYWORD_ONLY and name not in excluded - ) - seen[key] = RouteEntry(class_name, method_name, http_method, path, openapi, params) + seen[key] = RouteEntry(class_name, method_name, http_method, path, openapi, _request_model(member)) return list(seen.values()) @@ -105,12 +109,26 @@ def _resolve_ref(*, spec: dict, schema: dict) -> dict: return node -def _request_body_properties(*, spec: dict, operation: dict) -> set[str]: +def _request_body_properties(*, spec: dict, operation: dict) -> dict[str, dict]: content = operation.get("requestBody", {}).get("content", {}) for media_type in content.values(): schema = _resolve_ref(spec=spec, schema=media_type.get("schema", {})) - return set(schema.get("properties", {}).keys()) - return set() + return schema.get("properties", {}) + return {} + + +def _spec_request_fields(*, spec: dict, operation: dict) -> set[str]: + fields = { + parameter["name"] + for parameter in operation.get("parameters", []) + if parameter["in"] == QUERY_LOCATION and not parameter.get("deprecated", False) + } + fields |= { + name + for name, prop in _request_body_properties(spec=spec, operation=operation).items() + if not prop.get("deprecated", False) + } + return fields - IGNORE_PARAMS def check(spec: dict, routes: list[RouteEntry]) -> list[str]: @@ -125,11 +143,22 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: if operation is None: mismatches.append(f"{label}: route not found in spec") continue - allowed = {p["name"] for p in operation.get("parameters", [])} - if route.http_method.upper() in PATH_METHODS_WITH_BODY: - allowed |= _request_body_properties(spec=spec, operation=operation) + spec_fields = _spec_request_fields(spec=spec, operation=operation) + if route.request_model is None: + mismatches.extend( + f"{label}: spec has param '{field}' but the method takes no request model" + for field in sorted(spec_fields) + ) + continue + model = route.request_model + model_fields = set(model.model_fields) mismatches.extend( - f"{label}: param '{param}' not found in spec" for param in route.params if param not in allowed + f"{label}: field '{field}' of {model.__name__} not found in spec" + for field in sorted(model_fields - spec_fields) + ) + mismatches.extend( + f"{label}: spec param '{field}' not declared on {model.__name__}" + for field in sorted(spec_fields - model_fields) ) return mismatches From 8da7499fd4e5d490cd4a22dfd75d7fc42e6672bb Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:19:23 -0700 Subject: [PATCH 16/28] feat(cli): build request models from options; map pydantic errors to exit 2 Bad option and --param values now surface as the same ValidationError exit-2 path a server 422 takes, before any request is sent. A bare --param string for a list field is wrapped the way the API reads it. --- .../src/discolike_cli/_output.py | 30 +++++++++++++++ packages/discolike-cli/tests/test_output.py | 38 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index c9bce5a..f6d5fec 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -3,12 +3,15 @@ import functools import json import sys +import types +import typing from collections.abc import Callable from typing import Any from typing import ParamSpec from typing import Protocol from typing import TypeVar +import pydantic import typer from rich.console import Console from rich.table import Table @@ -24,6 +27,8 @@ P = ParamSpec("P") R = TypeVar("R") +RequestT = TypeVar("RequestT", bound=pydantic.BaseModel) +UNION_ORIGINS = (typing.Union, types.UnionType) EXIT_CODES: dict[type, int] = { ValidationError: 2, @@ -124,11 +129,36 @@ def call_typed(fn: Callable[..., R], **kwargs: Any) -> R: # noqa: ANN401 -- for raise typer.BadParameter(str(exc)) from exc +def _accepts_list(model: type[pydantic.BaseModel], name: str) -> bool: + field = model.model_fields.get(name) + if field is None: + return False + annotation = field.annotation + members = typing.get_args(annotation) if typing.get_origin(annotation) in UNION_ORIGINS else (annotation,) + return any(typing.get_origin(member) is list for member in members) + + +def build_request(model: type[RequestT], kwargs: dict[str, Any]) -> RequestT: + # --param KEY=VALUE yields a bare string; the API reads one value for a list param as a one-element list. + return model.model_validate( + { + key: [value] if isinstance(value, str) and _accepts_list(model, key) else value + for key, value in kwargs.items() + } + ) + + +def _validation_message(exc: pydantic.ValidationError) -> str: + return "; ".join(f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in exc.errors()) + + def handle_errors(fn: Callable[P, R]) -> Callable[P, R]: @functools.wraps(fn) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: return fn(*args, **kwargs) + except pydantic.ValidationError as exc: + raise fail(ValidationError(_validation_message(exc))) from exc except DiscolikeError as exc: raise fail(exc) from exc diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index 70b7907..f2ad7b8 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -4,6 +4,7 @@ import sys from collections.abc import Callable +import pydantic import pytest import typer @@ -11,8 +12,11 @@ from discolike import RateLimitError from discolike import ServerError from discolike import ValidationError +from discolike.requests import DiscoverParams +from discolike.requests import MatchCompanyParams from discolike.resources.discovery import Company from discolike_cli._output import EXIT_CODES +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import fail from discolike_cli._output import handle_errors @@ -251,3 +255,37 @@ def test_run_job_with_wait_emits_full_status_when_no_results(capsys: pytest.Capt run_job(job, wait=True, timeout=30.0) captured = capsys.readouterr() assert json.loads(captured.out) == {"progress": 100, "results": None} + + +def test_build_request_wraps_a_bare_string_for_list_fields() -> None: + request = build_request(DiscoverParams, {"subdomain": "shop", "country": ["DE"], "icp_prompt": "X"}) + assert request.to_wire() == {"subdomain": ["shop"], "country": ["DE"], "icp_prompt": "X"} + + +def test_build_request_coerces_scalar_strings_from_param() -> None: + request = build_request(DiscoverParams, {"min_similarity": "50", "redirect": "true"}) + assert request.to_wire() == {"min_similarity": 50, "redirect": True} + + +def test_build_request_passes_unknown_keys_through() -> None: + assert build_request(DiscoverParams, {"future_flag": "on"}).to_wire() == {"future_flag": "on"} + + +def test_build_request_raises_pydantic_validation_error_on_bad_values() -> None: + with pytest.raises(pydantic.ValidationError, match="min_similarity"): + build_request(DiscoverParams, {"min_similarity": "200"}) + + +def test_handle_errors_maps_pydantic_validation_error_to_exit_2(capsys: pytest.CaptureFixture[str]) -> None: + @handle_errors + def bad() -> None: + MatchCompanyParams.model_validate({"min_match_confidence": 10}) + + with pytest.raises(typer.Exit) as exc_info: + bad() + assert exc_info.value.exit_code == 2 + payload = json.loads(capsys.readouterr().err) + assert payload["error"] == "ValidationError" + assert payload["status_code"] is None + assert "name" in payload["message"] + assert "min_match_confidence" in payload["message"] From 80a0d4938c13d9c3704eda5bb554def13e096e02 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:22:00 -0700 Subject: [PATCH 17/28] feat(cli): company, match, discover and count build request models Option values are validated against the spec before the request goes out; --param keeps working as the escape hatch and now also validates known keys instead of forwarding them blind. --- .../src/discolike_cli/company.py | 40 +++++-- .../src/discolike_cli/discover.py | 100 ++++++++++-------- .../discolike-cli/src/discolike_cli/match.py | 34 +++--- packages/discolike-cli/tests/test_company.py | 26 +++-- packages/discolike-cli/tests/test_discover.py | 28 +++-- 5 files changed, 148 insertions(+), 80 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/company.py b/packages/discolike-cli/src/discolike_cli/company.py index 079f6c8..ed52872 100644 --- a/packages/discolike-cli/src/discolike_cli/company.py +++ b/packages/discolike-cli/src/discolike_cli/company.py @@ -2,8 +2,18 @@ import typer +from discolike.requests import CompaniesDataParams +from discolike.requests import CompaniesExtractParams +from discolike.requests import CompaniesGrowthParams +from discolike.requests import CompaniesPublicLinksParams +from discolike.requests import CompaniesRedirectsParams +from discolike.requests import CompaniesScoreParams +from discolike.requests import CompaniesSubsidiariesParams +from discolike.requests import CompaniesVendorsParams +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." DOMAIN_HELP = "Company domain, e.g. stripe.com" @@ -24,7 +34,9 @@ def data( """Full company profile (firmographics) for a domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.data(domain=domain), fmt=fmt) + emit( + get_client(ctx).companies.data(build_request(CompaniesDataParams, _merge_params(None, domain=domain))), fmt=fmt + ) @app.command() @@ -37,7 +49,10 @@ def score( """Company score for a domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.score(domain=domain), fmt=fmt) + emit( + get_client(ctx).companies.score(build_request(CompaniesScoreParams, _merge_params(None, domain=domain))), + fmt=fmt, + ) @app.command() @@ -50,7 +65,10 @@ def growth( """Growth signals for a domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.growth(domain=domain), fmt=fmt) + emit( + get_client(ctx).companies.growth(build_request(CompaniesGrowthParams, _merge_params(None, domain=domain))), + fmt=fmt, + ) @app.command() @@ -64,7 +82,8 @@ def redirects( """Domain redirects for a company domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.redirects(domain=domain, match=match), fmt=fmt) + request = build_request(CompaniesRedirectsParams, _merge_params(None, domain=domain, match=match)) + emit(get_client(ctx).companies.redirects(request), fmt=fmt) @app.command() @@ -78,7 +97,8 @@ def vendors( """Vendors associated with a company domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.vendors(domain=domain, match=match), fmt=fmt) + request = build_request(CompaniesVendorsParams, _merge_params(None, domain=domain, match=match)) + emit(get_client(ctx).companies.vendors(request), fmt=fmt) @app.command() @@ -92,7 +112,8 @@ def subsidiaries( """Subsidiaries of a company domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.subsidiaries(domain=domain, match=match), fmt=fmt) + request = build_request(CompaniesSubsidiariesParams, _merge_params(None, domain=domain, match=match)) + emit(get_client(ctx).companies.subsidiaries(request), fmt=fmt) @app.command(name="public-links") @@ -106,7 +127,8 @@ def public_links( """Public profile links for a domain from a given source.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.public_links(domain=domain, source=source), fmt=fmt) + request = build_request(CompaniesPublicLinksParams, _merge_params(None, domain=domain, source=source)) + emit(get_client(ctx).companies.public_links(request), fmt=fmt) @handle_errors @@ -118,4 +140,6 @@ def extract_command( """Extract page content from a URL.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.extract(url=url), fmt=fmt) + emit( + get_client(ctx).companies.extract(build_request(CompaniesExtractParams, _merge_params(None, url=url))), fmt=fmt + ) diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 02cc548..4719ada 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -4,7 +4,9 @@ import typer -from discolike_cli._output import call_typed +from discolike.requests import CountParams +from discolike.requests import DiscoverParams +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors @@ -65,33 +67,35 @@ def discover_command( """Discover companies matching your ICP and filters.""" from discolike_cli.main import get_client - kwargs = _merge_params( - param, - icp_prompt=icp_prompt, - domain=domain, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - category=category, - negate_category=negate_category, - country=country, - negate_country=negate_country, - state=state, - negate_state=negate_state, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - exclude_domain=exclude_domain, - exclusion_query_id=exclusion_query_id, - max_records=max_records, - offset=offset, + request = build_request( + DiscoverParams, + _merge_params( + param, + icp_prompt=icp_prompt, + domain=domain, + phrase_match=phrase_match, + negate_phrase_match=negate_phrase_match, + category=category, + negate_category=negate_category, + country=country, + negate_country=negate_country, + state=state, + negate_state=negate_state, + employee_range=employee_range, + revenue_range=revenue_range, + business_model=business_model, + negate_business_model=negate_business_model, + tech_stack=tech_stack, + negate_tech_stack=negate_tech_stack, + min_digital_footprint=min_digital_footprint, + max_digital_footprint=max_digital_footprint, + exclude_domain=exclude_domain, + exclusion_query_id=exclusion_query_id, + max_records=max_records, + offset=offset, + ), ) - companies = call_typed(get_client(ctx).discover, **kwargs) - emit(companies, fmt=fmt) + emit(get_client(ctx).discover(request), fmt=fmt) @handle_errors @@ -121,24 +125,26 @@ def count_command( """Count companies matching the given filters.""" from discolike_cli.main import get_client - kwargs = _merge_params( - param, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - category=category, - negate_category=negate_category, - country=country, - negate_country=negate_country, - state=state, - negate_state=negate_state, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, + request = build_request( + CountParams, + _merge_params( + param, + phrase_match=phrase_match, + negate_phrase_match=negate_phrase_match, + category=category, + negate_category=negate_category, + country=country, + negate_country=negate_country, + state=state, + negate_state=negate_state, + employee_range=employee_range, + revenue_range=revenue_range, + business_model=business_model, + negate_business_model=negate_business_model, + tech_stack=tech_stack, + negate_tech_stack=negate_tech_stack, + min_digital_footprint=min_digital_footprint, + max_digital_footprint=max_digital_footprint, + ), ) - count = call_typed(get_client(ctx).count, **kwargs) - emit(count, fmt=fmt) + emit(get_client(ctx).count(request), fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/match.py b/packages/discolike-cli/src/discolike_cli/match.py index 5c60217..2a162c2 100644 --- a/packages/discolike-cli/src/discolike_cli/match.py +++ b/packages/discolike-cli/src/discolike_cli/match.py @@ -4,9 +4,13 @@ import typer +from discolike.requests import MatchBulkParams +from discolike.requests import MatchCompanyParams +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 DEFAULT_NAME_COLUMN = "name" @@ -45,19 +49,25 @@ def match_command( client = get_client(ctx) if name is not None: - response = client.match.company( - name=name, - phone=phone, - city=city, - state=state, - country=country, - zip_code=zip_code, - strict=strict, - local_mode=local_mode, + request = build_request( + MatchCompanyParams, + _merge_params( + None, + name=name, + phone=phone, + city=city, + state=state, + country=country, + zip_code=zip_code, + strict=strict, + local_mode=local_mode, + ), ) - emit(response, fmt=fmt) + emit(client.match.company(request), fmt=fmt) return assert file is not None - job = client.match.bulk(file=file, name_column=name_column, strict=strict, local_mode=local_mode) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + request = build_request( + MatchBulkParams, _merge_params(None, name_column=name_column, strict=strict, local_mode=local_mode) + ) + run_job(client.match.bulk(request, file=file), wait=wait, timeout=timeout, fmt=fmt) diff --git a/packages/discolike-cli/tests/test_company.py b/packages/discolike-cli/tests/test_company.py index eefc90e..729f6ca 100644 --- a/packages/discolike-cli/tests/test_company.py +++ b/packages/discolike-cli/tests/test_company.py @@ -66,18 +66,30 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert rows[0]["linked_domain"] == "acme.io" -@pytest.mark.parametrize("subcommand", ["redirects", "vendors", "subsidiaries"]) -def test_company_match_option_forwarded(subcommand: str, install_build_client: Callable[[Handler], None]) -> None: +@pytest.mark.parametrize( + ("subcommand", "mode"), + [("redirects", "linked"), ("vendors", "vendor"), ("subsidiaries", "child")], +) +def test_company_match_option_forwarded( + subcommand: str, mode: str, install_build_client: Callable[[Handler], None] +) -> None: captured: dict[str, httpx2.Request] = {} def handler(request: httpx2.Request) -> httpx2.Response: captured["request"] = request - return httpx2.Response(200, json=[{"linked_domain": "acme.io"}]) + return httpx2.Response(200, json=[]) install_build_client(handler) - result = runner.invoke(app, ["company", subcommand, "acme.com", "--match", "loose"]) + result = runner.invoke(app, ["company", subcommand, "acme.com", "--match", mode]) assert result.exit_code == 0, result.output - assert captured["request"].url.params.get("match") == "loose" + assert captured["request"].url.params.get("match") == mode + + +def test_company_match_option_rejects_unknown_mode(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["company", "redirects", "acme.com", "--match", "loose"]) + assert result.exit_code == 2 + assert json.loads(result.stderr)["error"] == "ValidationError" def test_company_public_links_hits_publiclink_endpoint(install_build_client: Callable[[Handler], None]) -> None: @@ -88,12 +100,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"linked_domain": "acme.io"}]) install_build_client(handler) - result = runner.invoke(app, ["company", "public-links", "acme.com", "--source", "crunchbase"]) + result = runner.invoke(app, ["company", "public-links", "acme.com", "--source", "social"]) assert result.exit_code == 0, result.output request = captured["request"] assert request.url.path == "/v1/publiclink" assert request.url.params.get("domain") == "acme.com" - assert request.url.params.get("source") == "crunchbase" + assert request.url.params.get("source") == "social" def test_company_public_links_requires_source(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_discover.py b/packages/discolike-cli/tests/test_discover.py index 38a93a6..7f3f8c9 100644 --- a/packages/discolike-cli/tests/test_discover.py +++ b/packages/discolike-cli/tests/test_discover.py @@ -34,13 +34,13 @@ def handler(request: httpx2.Request) -> httpx2.Response: install_build_client(handler) result = runner.invoke( app, - ["discover", "--icp-prompt", "X", "--country", "DE", "--param", "min_similarity=200"], + ["discover", "--icp-prompt", "X", "--country", "DE", "--param", "min_similarity=50"], ) assert result.exit_code == 0, result.output params = captured["params"] assert params.get("icp_prompt") == "X" assert params.get_list("country") == ["DE"] - assert params.get("min_similarity") == "200" + assert params.get("min_similarity") == "50" stdout = json.loads(result.stdout) assert stdout[0]["domain"] == "acme.com" @@ -53,9 +53,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return _discover_ok(request) install_build_client(handler) - result = runner.invoke(app, ["discover", "--param", "social=linkedin,github"]) + result = runner.invoke(app, ["discover", "--param", "social=linkedin,youtube"]) assert result.exit_code == 0, result.output - assert captured["params"].get_list("social") == ["linkedin", "github"] + assert captured["params"].get_list("social") == ["linkedin", "youtube"] def test_discover_param_without_equals_exits_2(install_build_client: Callable[[Handler], None]) -> None: @@ -64,10 +64,26 @@ def test_discover_param_without_equals_exits_2(install_build_client: Callable[[H assert result.exit_code == 2 -def test_discover_param_removed_kwarg_exits_2(install_build_client: Callable[[Handler], None]) -> None: - install_build_client(_discover_ok) +def test_discover_param_unknown_key_passes_through(install_build_client: Callable[[Handler], None]) -> None: + captured: dict[str, httpx2.QueryParams] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + captured["params"] = request.url.params + return _discover_ok(request) + + install_build_client(handler) result = runner.invoke(app, ["discover", "--param", "min_score=1"]) + assert result.exit_code == 0, result.output + assert captured["params"].get("min_score") == "1" + + +def test_discover_param_out_of_range_exits_2(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_discover_ok) + result = runner.invoke(app, ["discover", "--param", "min_similarity=200"]) assert result.exit_code == 2 + payload = json.loads(result.stderr) + assert payload["error"] == "ValidationError" + assert "min_similarity" in payload["message"] def test_discover_explicit_option_wins_over_param_duplicate(install_build_client: Callable[[Handler], None]) -> None: From e0fa9f7d1021c40bbbeabc1ce71bd17981241b6a Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:27:12 -0700 Subject: [PATCH 18/28] feat(cli): contacts and email commands build request models Seniority/department/industry values are checked against the spec's enums locally; the tests that relied on made-up values now use real ones. --- .../src/discolike_cli/contacts.py | 61 +++++++++++-------- .../discolike-cli/src/discolike_cli/email.py | 12 +++- .../discolike-cli/tests/test_contacts_cli.py | 36 +++++++---- .../discolike-cli/tests/test_email_cli.py | 10 +++ 4 files changed, 79 insertions(+), 40 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index 9c6a23c..ecc1a10 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -5,7 +5,14 @@ import typer -from discolike_cli._output import call_typed +from discolike.requests import BulkContactMatchRequest +from discolike.requests import ContactFilters +from discolike.requests import ContactGenerateRequest +from discolike.requests import ContactsCountParams +from discolike.requests import ContactsLookupParams +from discolike.requests import ContactsMatchParams +from discolike.requests import ContactsSearchParams +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job @@ -77,7 +84,7 @@ def search_command( max_records=max_records, offset=offset, ) - emit(call_typed(get_client(ctx).contacts.search, **kwargs), fmt=fmt) + emit(get_client(ctx).contacts.search(build_request(ContactsSearchParams, kwargs)), fmt=fmt) @app.command("count") @@ -129,7 +136,7 @@ def count_command( has_email=has_email, jobstart_date=jobstart_date, ) - emit(call_typed(get_client(ctx).contacts.count, **kwargs), fmt=fmt) + emit(get_client(ctx).contacts.count(build_request(ContactsCountParams, kwargs)), fmt=fmt) @app.command("lookup") @@ -144,7 +151,10 @@ def lookup_command( """Look up a single contact by persona ID, LinkedIn URL, or email.""" from discolike_cli.main import get_client - emit(get_client(ctx).contacts.lookup(persona_id=persona_id, linkedin=linkedin, email=email), fmt=fmt) + request = build_request( + ContactsLookupParams, _merge_params(None, persona_id=persona_id, linkedin=linkedin, email=email) + ) + emit(get_client(ctx).contacts.lookup(request), fmt=fmt) @app.command("match") @@ -161,16 +171,13 @@ def match_command( """Match a person name to contact records.""" from discolike_cli.main import get_client - emit( - get_client(ctx).contacts.match( - name=name, - company_name=company_name, - domain=domain, - person_country=person_country, - limit=limit, + request = build_request( + ContactsMatchParams, + _merge_params( + None, name=name, company_name=company_name, domain=domain, person_country=person_country, limit=limit ), - fmt=fmt, ) + emit(get_client(ctx).contacts.match(request), fmt=fmt) @app.command("bulk-match") @@ -198,8 +205,8 @@ def bulk_match_command( if not isinstance(queries, list): raise typer.BadParameter("--queries-file must contain a JSON array of objects") - job = get_client(ctx).contacts.bulk_match(queries=queries, enrich=enrich, limit=limit) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + request = build_request(BulkContactMatchRequest, _merge_params(None, queries=queries, enrich=enrich, limit=limit)) + run_job(get_client(ctx).contacts.bulk_match(request), wait=wait, timeout=timeout, fmt=fmt) @app.command("discover") @@ -267,7 +274,7 @@ def discover_command( include_search_contacts=include_search_contacts, consensus=consensus, ) - emit(call_typed(get_client(ctx).contacts.discover, **kwargs), fmt=fmt) + emit(get_client(ctx).contacts.discover(build_request(ContactFilters, kwargs)), fmt=fmt) @app.command("generate") @@ -297,14 +304,18 @@ def generate_command( """Generate contacts for target domains from an ICP description (async job).""" from discolike_cli.main import get_client - job = get_client(ctx).contacts.generate( - icp_text=icp_text, - domains=domain, - context_mode=context_mode, - integration_id=integration_id, - search_provider_id=search_provider_id, - search_context_size=search_context_size, - max_contacts_per_domain=max_contacts_per_domain, - max_company_records=max_company_records, + request = build_request( + ContactGenerateRequest, + _merge_params( + None, + icp_text=icp_text, + domains=domain, + context_mode=context_mode, + integration_id=integration_id, + search_provider_id=search_provider_id, + search_context_size=search_context_size, + max_contacts_per_domain=max_contacts_per_domain, + max_company_records=max_company_records, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).contacts.generate(request), wait=wait, timeout=timeout, fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/email.py b/packages/discolike-cli/src/discolike_cli/email.py index 321ef09..3786bac 100644 --- a/packages/discolike-cli/src/discolike_cli/email.py +++ b/packages/discolike-cli/src/discolike_cli/email.py @@ -10,8 +10,12 @@ from discolike._email import EmailBatchResults from discolike._email import EmailJobResult from discolike._exceptions import JobTimeoutError +from discolike.requests import FindEmailBatchRequest +from discolike.requests import FindEmailRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 MAX_BATCH_CONTACTS = 500 @@ -81,9 +85,11 @@ def find_command( """Submit a single email find job (async); only a proven address bills.""" from discolike_cli.main import get_client - job = get_client(ctx).email.find( - first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern + request = build_request( + FindEmailRequest, + _merge_params(None, first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern), ) + job = get_client(ctx).email.find(request) if not wait: emit({"job_id": job.job_id, "hint": f"poll with: discolike email job {job.job_id}"}) return @@ -118,7 +124,7 @@ def find_batch_command( if len(contacts) > MAX_BATCH_CONTACTS: raise typer.BadParameter(f"a batch holds at most {MAX_BATCH_CONTACTS} contacts, got {len(contacts)}") - batch = get_client(ctx).email.find_batch(contacts=contacts) + batch = get_client(ctx).email.find_batch(build_request(FindEmailBatchRequest, {"requests": contacts})) if not wait: emit({"batch_id": batch.batch_id, "hint": f"fetch with: discolike email results {batch.batch_id}"}) return diff --git a/packages/discolike-cli/tests/test_contacts_cli.py b/packages/discolike-cli/tests/test_contacts_cli.py index ee5cc65..5a4acf7 100644 --- a/packages/discolike-cli/tests/test_contacts_cli.py +++ b/packages/discolike-cli/tests/test_contacts_cli.py @@ -31,7 +31,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: "--seniority", "vp", "--department", - "marketing", + "Sales - Marketing", "--title", "VP Marketing", "--domain", @@ -48,7 +48,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: "--jobstart-date", "2025-01-01,2025-06-30", "--max-records", - "10", + "20", "--offset", "5", "--param", @@ -59,7 +59,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: params = captured["params"] assert params.get("icp_prompt") == "VPs of Marketing" assert params.get_list("seniority") == ["vp"] - assert params.get_list("department") == ["marketing"] + assert params.get_list("department") == ["Sales - Marketing"] assert params.get_list("title") == ["VP Marketing"] assert params.get_list("domain") == ["acme.com"] assert params.get_list("person_country") == ["US"] @@ -68,7 +68,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert params.get("employee_range") == "50-200" assert params.get("has_email") == "true" assert params.get("jobstart_date") == "2025-01-01,2025-06-30" - assert params.get("max_records") == "10" + assert params.get("max_records") == "20" assert params.get("offset") == "5" assert params.get("min_connections") == "5" assert json.loads(result.stdout) == [ @@ -90,26 +90,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: "contacts", "search", "--negate-seniority", - "intern", + "entry_level", "--negate-department", - "hr", + "Human Resources", "--negate-title", "Assistant", "--negate-person-country", "FR", "--negate-filter-industry", - "GAMBLING", + "GAMING_AND_ESPORTS", "--negate-filter-country", "RU", ], ) assert result.exit_code == 0, result.output params = captured["params"] - assert params.get_list("negate_seniority") == ["intern"] - assert params.get_list("negate_department") == ["hr"] + assert params.get_list("negate_seniority") == ["entry_level"] + assert params.get_list("negate_department") == ["Human Resources"] assert params.get_list("negate_title") == ["Assistant"] assert params.get_list("negate_person_country") == ["FR"] - assert params.get_list("negate_filter_industry") == ["GAMBLING"] + assert params.get_list("negate_filter_industry") == ["GAMING_AND_ESPORTS"] assert params.get_list("negate_filter_country") == ["RU"] @@ -122,14 +122,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert result.exit_code == 2 -def test_contacts_search_invalid_param_kwarg_exits_2(install_build_client: Callable[[Handler], None]) -> None: +def test_contacts_search_unknown_param_passes_through(install_build_client: Callable[[Handler], None]) -> None: + captured: dict[str, httpx2.QueryParams] = {} + def handler(request: httpx2.Request) -> httpx2.Response: + captured["params"] = request.url.params return httpx2.Response(200, json=[]) install_build_client(handler) result = runner.invoke(app, ["contacts", "search", "--param", "bogus_kwarg=1"]) + assert result.exit_code == 0, result.output + assert captured["params"].get("bogus_kwarg") == "1" + + +def test_contacts_search_invalid_seniority_exits_2(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["contacts", "search", "--seniority", "intern"]) assert result.exit_code == 2 - assert "bogus_kwarg" in result.output + payload = json.loads(result.stderr) + assert payload["error"] == "ValidationError" + assert "seniority" in payload["message"] def test_contacts_search_unauthorized_exits_3(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_email_cli.py b/packages/discolike-cli/tests/test_email_cli.py index 776ee5b..37dfc21 100644 --- a/packages/discolike-cli/tests/test_email_cli.py +++ b/packages/discolike-cli/tests/test_email_cli.py @@ -200,6 +200,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert "500" in result.output +def test_email_find_batch_rejects_more_than_500_before_any_request( + tmp_path, install_build_client: Callable[[Handler], None] +) -> None: + contacts_file = tmp_path / "contacts.csv" + contacts_file.write_text("first_name,last_name,domain\n" + "Jane,Doe,acme.com\n" * 501) + install_build_client(lambda request: httpx2.Response(200, json={"batch_id": "eb-x"})) + result = runner.invoke(app, ["email", "find-batch", "--contacts-file", str(contacts_file)]) + assert result.exit_code == 2 + + def test_email_results_without_wait_returns_partial_snapshot( install_build_client: Callable[[Handler], None], ) -> None: From fb88ba7dcb3ae6e7cdf4c75d1e5d7021e0d2b3fd Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:28:32 -0700 Subject: [PATCH 19/28] feat(cli): queries and provider commands build request models llm-providers update keeps sending an explicit null api_key: the platform reads null as 'keep the stored key' and the field is required. --- .../src/discolike_cli/providers.py | 68 ++++++++++--------- .../src/discolike_cli/queries.py | 37 +++++----- 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/providers.py b/packages/discolike-cli/src/discolike_cli/providers.py index 3e568f2..93d8701 100644 --- a/packages/discolike-cli/src/discolike_cli/providers.py +++ b/packages/discolike-cli/src/discolike_cli/providers.py @@ -2,8 +2,13 @@ import typer +from discolike.requests import LLMProviderCreateRequest +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import SearchProviderRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." @@ -36,15 +41,18 @@ def search_create_command( """Create a search provider integration (connectivity is validated first).""" from discolike_cli.main import get_client - emit( - get_client(ctx).search_providers.create( + request = build_request( + SearchProviderRequest, + _merge_params( + None, integration_name=name, provider=provider, search_model=search_model, api_key=api_key, base_url=base_url, - ) + ), ) + emit(get_client(ctx).search_providers.create(request)) @search_providers_app.command("update") @@ -61,16 +69,18 @@ def search_update_command( """Update a search provider integration.""" from discolike_cli.main import get_client - emit( - get_client(ctx).search_providers.update( - integration_id=integration_id, + request = build_request( + SearchProviderRequest, + _merge_params( + None, integration_name=name, provider=provider, search_model=search_model, api_key=api_key, base_url=base_url, - ) + ), ) + emit(get_client(ctx).search_providers.update(request, integration_id=integration_id)) @search_providers_app.command("delete") @@ -147,15 +157,13 @@ def llm_create_command( """Create an LLM provider integration.""" from discolike_cli.main import get_client - emit( - get_client(ctx).llm_providers.create( - integration_name=name, - provider=provider, - api_key=api_key, - model_name=model_name, - base_url=base_url, - ) + request = build_request( + LLMProviderCreateRequest, + _merge_params( + None, integration_name=name, provider=provider, api_key=api_key, model_name=model_name, base_url=base_url + ), ) + emit(get_client(ctx).llm_providers.create(request)) @llm_providers_app.command("get") @@ -185,16 +193,14 @@ def llm_update_command( """Update an LLM provider integration.""" from discolike_cli.main import get_client - emit( - get_client(ctx).llm_providers.update( - integration_id=integration_id, - integration_name=name, - provider=provider, - model_name=model_name, - api_key=api_key, - base_url=base_url, - ) + request = build_request( + LLMProviderUpdateRequest, + { + **_merge_params(None, integration_name=name, provider=provider, model_name=model_name, base_url=base_url), + "api_key": api_key, + }, ) + emit(get_client(ctx).llm_providers.update(request, integration_id=integration_id)) @llm_providers_app.command("delete") @@ -235,12 +241,10 @@ def llm_test_connection_command( """Test a provider configuration before saving it.""" from discolike_cli.main import get_client - emit( - get_client(ctx).llm_providers.test_connection( - integration_name=name, - provider=provider, - api_key=api_key, - model_name=model_name, - base_url=base_url, - ) + request = build_request( + LLMProviderCreateRequest, + _merge_params( + None, integration_name=name, provider=provider, api_key=api_key, model_name=model_name, base_url=base_url + ), ) + emit(get_client(ctx).llm_providers.test_connection(request)) diff --git a/packages/discolike-cli/src/discolike_cli/queries.py b/packages/discolike-cli/src/discolike_cli/queries.py index a337a13..71a23f8 100644 --- a/packages/discolike-cli/src/discolike_cli/queries.py +++ b/packages/discolike-cli/src/discolike_cli/queries.py @@ -7,8 +7,14 @@ import typer +from discolike.requests import CreateExclusionListRequest +from discolike.requests import QueriesListParams +from discolike.requests import SaveResultsRequest +from discolike.requests import UpdateQueryRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." @@ -36,7 +42,10 @@ def list_command( """List saved queries.""" from discolike_cli.main import get_client - emit(get_client(ctx).queries.list(max_records=max_records, offset=offset, action=action, tags=tag), fmt=fmt) + request = build_request( + QueriesListParams, _merge_params(None, max_records=max_records, offset=offset, action=action, tags=tag) + ) + emit(get_client(ctx).queries.list(request), fmt=fmt) @app.command("create-exclusion-list") @@ -51,14 +60,11 @@ def create_exclusion_list_command( """Create a named exclusion list of domains and/or persona IDs.""" from discolike_cli.main import get_client - emit( - get_client(ctx).queries.create_exclusion_list( - query_name=name, - domains=domain, - persona_ids=persona_id, - tags=tag, - ) + request = build_request( + CreateExclusionListRequest, + _merge_params(None, query_name=name, domains=domain, persona_ids=persona_id, tags=tag), ) + emit(get_client(ctx).queries.create_exclusion_list(request)) @app.command("save-results") @@ -89,15 +95,11 @@ def save_results_command( except json.JSONDecodeError as exc: raise typer.BadParameter(f"--input file {input_path} must contain valid JSON: {exc}") from exc - emit( - get_client(ctx).queries.save_results( - query_name=name, - action=action.value, - data=data, - domain_column=domain_column, - tags=tag, - ) + request = build_request( + SaveResultsRequest, + _merge_params(None, query_name=name, action=action.value, data=data, domain_column=domain_column, tags=tag), ) + emit(get_client(ctx).queries.save_results(request)) @app.command("update") @@ -111,7 +113,8 @@ def update_command( """Rename a saved query and/or update its tags.""" from discolike_cli.main import get_client - emit(get_client(ctx).queries.update(query_id=query_id, query_name=name, tags=tag)) + request = build_request(UpdateQueryRequest, _merge_params(None, query_name=name, tags=tag)) + emit(get_client(ctx).queries.update(request, query_id=query_id)) @app.command("delete") From b4f58532b1a129ee5768e6c78813bd532f65069a Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:30:23 -0700 Subject: [PATCH 20/28] feat(cli): discogen, validate-icp, append and segment build request models segment dispatches to segment/segment_file explicitly instead of relying on the removed SDK wrapper; --domain values are joined into the comma-separated string the spec declares. call_typed goes with the last call site: the SDK no longer raises TypeError for unknown kwargs, and unknown --param keys pass through by design. --- .../src/discolike_cli/_output.py | 7 --- .../src/discolike_cli/discogen.py | 52 ++++++++++++------- .../discolike-cli/src/discolike_cli/enrich.py | 45 ++++++++++------ .../discolike-cli/tests/test_enrich_cli.py | 22 +++++++- packages/discolike-cli/tests/test_output.py | 6 +++ 5 files changed, 88 insertions(+), 44 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index f6d5fec..593a8ed 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -122,13 +122,6 @@ def fail(exc: DiscolikeError) -> typer.Exit: return typer.Exit(code=EXIT_CODES.get(type(exc), DEFAULT_EXIT_CODE)) -def call_typed(fn: Callable[..., R], **kwargs: Any) -> R: # noqa: ANN401 -- forwarded to a typed SDK method signature - try: - return fn(**kwargs) - except TypeError as exc: - raise typer.BadParameter(str(exc)) from exc - - def _accepts_list(model: type[pydantic.BaseModel], name: str) -> bool: field = model.model_fields.get(name) if field is None: diff --git a/packages/discolike-cli/src/discolike_cli/discogen.py b/packages/discolike-cli/src/discolike_cli/discogen.py index 9ff7347..fbb33be 100644 --- a/packages/discolike-cli/src/discolike_cli/discogen.py +++ b/packages/discolike-cli/src/discolike_cli/discogen.py @@ -5,9 +5,13 @@ import typer from discolike._jobs import Job +from discolike.requests import DiscoGenPersonaProcessRequest +from discolike.requests import DiscoGenProcessRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 @@ -58,17 +62,21 @@ def run_command( """Run a DiscoGen research query across company domains (async job).""" from discolike_cli.main import get_client - job = get_client(ctx).discogen.process( - query=query, - domains=domain, - integration_id=integration_id, - web_search=web_search, - context_mode=context_mode, - include_x_search=include_x_search, - search_provider_id=search_provider_id, - search_context_size=search_context_size, + request = build_request( + DiscoGenProcessRequest, + _merge_params( + None, + query=query, + domains=domain, + integration_id=integration_id, + web_search=web_search, + context_mode=context_mode, + include_x_search=include_x_search, + search_provider_id=search_provider_id, + search_context_size=search_context_size, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).discogen.process(request), wait=wait, timeout=timeout, fmt=fmt) @app.command("run-personas") @@ -92,17 +100,21 @@ def run_personas_command( """Run a DiscoGen research query across personas (async job).""" from discolike_cli.main import get_client - job = get_client(ctx).discogen.process_personas( - query=query, - persona_ids=persona_id, - integration_id=integration_id, - web_search=web_search, - context_mode=context_mode, - include_x_search=include_x_search, - search_provider_id=search_provider_id, - search_context_size=search_context_size, + request = build_request( + DiscoGenPersonaProcessRequest, + _merge_params( + None, + query=query, + persona_ids=persona_id, + integration_id=integration_id, + web_search=web_search, + context_mode=context_mode, + include_x_search=include_x_search, + search_provider_id=search_provider_id, + search_context_size=search_context_size, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).discogen.process_personas(request), wait=wait, timeout=timeout, fmt=fmt) @app.command("models") diff --git a/packages/discolike-cli/src/discolike_cli/enrich.py b/packages/discolike-cli/src/discolike_cli/enrich.py index 6e13d9f..a45aa52 100644 --- a/packages/discolike-cli/src/discolike_cli/enrich.py +++ b/packages/discolike-cli/src/discolike_cli/enrich.py @@ -4,9 +4,15 @@ import typer +from discolike.requests import AppendParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams +from discolike.requests import ValidateIcpRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 @@ -49,15 +55,19 @@ def validate_icp_command( assert domain is not None domains = domain - job = get_client(ctx).validate_icp( - icp_text=icp, - domains=domains, - context_mode=context_mode, - integration_id=integration_id, - web_search=web_search, - search_provider_id=search_provider_id, + request = build_request( + ValidateIcpRequest, + _merge_params( + None, + icp_text=icp, + domains=domains, + context_mode=context_mode, + integration_id=integration_id, + web_search=web_search, + search_provider_id=search_provider_id, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).validate_icp(request), wait=wait, timeout=timeout, fmt=fmt) @handle_errors @@ -75,7 +85,8 @@ def append_command( """Enrich a CSV of domains with DiscoLike datasets.""" from discolike_cli.main import get_client - result = get_client(ctx).append(file=file, dataset=dataset, domain_column=domain_column, csv=csv) + request = build_request(AppendParams, _merge_params(None, dataset=dataset, domain_column=domain_column, csv=csv)) + result = get_client(ctx).append(request, file=file) if isinstance(result, bytes): if output is None: raise typer.BadParameter("--output is required when the response is CSV bytes") @@ -102,10 +113,14 @@ def segment_command( if (not domain) == (file is None): raise typer.BadParameter("Provide exactly one of --domain or --file") - job = get_client(ctx).segment( - domains=domain or None, - file=file, - domain_column=domain_column, - max_segments=max_segments, - ) + client = get_client(ctx) + if file is not None: + request = build_request( + SegmentFileParams, _merge_params(None, domain_column=domain_column, max_segments=max_segments) + ) + job = client.segment_file(request, file=file) + else: + assert domain is not None + request = build_request(SegmentParams, _merge_params(None, domains=",".join(domain), max_segments=max_segments)) + job = client.segment(request) run_job(job, wait=wait, timeout=timeout, fmt=fmt) diff --git a/packages/discolike-cli/tests/test_enrich_cli.py b/packages/discolike-cli/tests/test_enrich_cli.py index 6210386..47fdab8 100644 --- a/packages/discolike-cli/tests/test_enrich_cli.py +++ b/packages/discolike-cli/tests/test_enrich_cli.py @@ -140,7 +140,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=b"domain,industry\nacme.com,SAAS\n", headers={"Content-Type": "text/csv"}) install_build_client(handler) - result = runner.invoke(app, ["append", str(input_file), "--csv", "--output", str(output_file)]) + result = runner.invoke( + app, ["append", str(input_file), "--dataset", "bizdata", "--csv", "--output", str(output_file)] + ) assert result.exit_code == 0, result.output assert output_file.read_bytes() == b"domain,industry\nacme.com,SAAS\n" payload = json.loads(result.stdout) @@ -156,8 +158,24 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=b"domain\nacme.com\n", headers={"Content-Type": "text/csv"}) install_build_client(handler) - result = runner.invoke(app, ["append", str(input_file), "--csv"]) + result = runner.invoke(app, ["append", str(input_file), "--dataset", "bizdata", "--csv"]) + assert result.exit_code == 2 + + +def test_append_without_dataset_exits_2(tmp_path, install_build_client: Callable[[Handler], None]) -> None: + input_file = tmp_path / "domains.csv" + input_file.write_text("domain\nacme.com\n") + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["append", str(input_file)]) + assert result.exit_code == 2 + assert "dataset" in json.loads(result.stderr)["message"] + + +def test_segment_max_segments_out_of_range_exits_2(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json={"task_id": "seg-9"})) + result = runner.invoke(app, ["segment", "--domain", "acme.com", "--max-segments", "99"]) assert result.exit_code == 2 + assert "max_segments" in json.loads(result.stderr)["message"] def test_segment_with_domain_options_prints_task_hint(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index f2ad7b8..106fa1c 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -276,6 +276,12 @@ def test_build_request_raises_pydantic_validation_error_on_bad_values() -> None: build_request(DiscoverParams, {"min_similarity": "200"}) +def test_call_typed_is_gone() -> None: + import discolike_cli._output as output_module + + assert not hasattr(output_module, "call_typed") + + def test_handle_errors_maps_pydantic_validation_error_to_exit_2(capsys: pytest.CaptureFixture[str]) -> None: @handle_errors def bad() -> None: From 950748c35dd385a1f9bf0299f3671e8ccd1152d5 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:37:55 -0700 Subject: [PATCH 21/28] release: 0.3.0 request models Breaking SDK surface (models instead of kwargs) warrants the minor bump; CLI pins the SDK exactly as before. --- CHANGELOG.md | 28 +++++++++++ README.md | 51 +++++++++++++++----- examples/discover_and_enrich.py | 12 +++-- examples/find_emails_from_csv.py | 5 +- examples/match_crm_contacts.py | 7 ++- packages/discolike-cli/pyproject.toml | 4 +- packages/discolike/README.md | 16 ++++-- packages/discolike/pyproject.toml | 2 +- packages/discolike/src/discolike/_version.py | 2 +- packages/discolike/tests/test_package.py | 2 +- uv.lock | 2 +- 11 files changed, 100 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f2127..5696352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.3.0 (2026-08-27) + +- SDK (breaking): every request-taking method now takes a single request model instead of keyword arguments, and validates it locally before any HTTP call — a bad enum value, an out-of-range number, or a missing required field raises `pydantic.ValidationError` instead of a server 422. Models live in `discolike.requests` and are generated from the platform OpenAPI spec (`scripts/gen_requests.py`); query-param routes use `Params` (`MatchCompanyParams`, `ContactsSearchParams`, `DiscoverParams`, `CountParams`, `AppendParams`, `SegmentParams`, ...) and JSON-body routes use the platform's own names (`FindEmailRequest`, `ContactFilters`, `DiscoGenProcessRequest`, `UpdateQueryRequest`, ...). Path params and file uploads stay keyword arguments next to the model. Unknown fields pass through to the wire, so the SDK never blocks a platform field it does not know about yet. + + ```python + # before + client.match.company(name="Acme Inc", city="Austin", min_match_confidence=80) + client.email.find_batch(contacts=[{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}]) + client.queries.update(query_id="q3", query_name="New Name") + client.segment(domains=["acme.com", "beta.com"], max_segments=5) + client.segment(file="domains.csv", domain_column="domain") + + # after + from discolike.requests import FindEmailBatchRequest, MatchCompanyParams, SegmentFileParams, SegmentParams, UpdateQueryRequest + + client.match.company(MatchCompanyParams(name="Acme Inc", city="Austin", min_match_confidence=80)) + client.email.find_batch(FindEmailBatchRequest.model_validate({"requests": [{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}]})) + client.queries.update(UpdateQueryRequest(query_name="New Name"), query_id="q3") + client.segment(SegmentParams(domains="acme.com,beta.com", max_segments=5)) + client.segment_file(SegmentFileParams(domain_column="domain"), file="domains.csv") + ``` + +- SDK (breaking): `segment` is split into `client.segment(SegmentParams)` (`GET /segment`, `domains` is the comma-separated string the API takes) and `client.segment_file(SegmentFileParams, file=...)` (`POST /segment`). `email.find_batch` drops its `contacts=` alias for the platform's `requests` field. `append` requires `dataset`, matching the platform. +- SDK: deprecated parameters (`nl_match`, `min_score`, `negate_domain`, `exact_match`, `vendor`, `revenue_range` on contacts, `negate_icp_text`) are not part of the generated models; they still pass through as extra fields if set explicitly. +- SDK: new `DiscolikeRequest` base (`discolike.DiscolikeRequest`) with `to_wire()`, which sends exactly the fields you set — an explicit `None` goes out as `null` (this is how `llm_providers.update` keeps the stored API key), and unset fields are omitted so server defaults keep governing. +- CLI: request models are built from the same options as before, so no flags change. Two behavior changes: `--param KEY=VALUE` with an unknown key is forwarded to the API (previously exit 2), and any option or `--param` value outside the spec (an unknown `--department`, `--max-records` below the floor, `--match loose`) exits 2 with `{"error": "ValidationError", ...}` on stderr before the request is sent. +- CI: the contract job also runs `scripts/gen_requests.py --check`, so the committed models fail the build when the platform spec moves. + ## 0.2.0 (2026-08-21) - SDK + testkit (breaking): migrated from `httpx` to [`httpx2`](https://github.com/pydantic/httpx2), Pydantic's maintained continuation of httpx, for timely security updates. `httpx` types are part of the public surface (`http_client=`, `with_options(timeout=)`, the testkit `Handler` alias), so callers must swap `import httpx` for `import httpx2` and pass `httpx2.Client` / `httpx2.AsyncClient` / `httpx2.Timeout`. Note httpx2 verifies TLS against the OS trust store via `truststore` instead of bundled `certifi` roots. diff --git a/README.md b/README.md index c7577c0..49a6b09 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,16 @@ client = Discolike(api_key="dl_...") # or pass it explicitly ```python from discolike import Discolike +from discolike.requests import DiscoverParams client = Discolike() companies = client.discover( - icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", - country=["US"], - max_records=25, + DiscoverParams( + icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", + country=["US"], + max_records=25, + ) ) for company in companies: print(company.domain, company.name, company.similarity) @@ -97,10 +100,14 @@ for company in companies: Run DiscoGen research over a set of domains and wait for the result: ```python +from discolike.requests import DiscoGenProcessRequest + job = client.discogen.process( - query="Recent funding rounds and headcount growth", - domains=["stripe.com", "adyen.com"], - web_search=True, + DiscoGenProcessRequest( + query="Recent funding rounds and headcount growth", + domains=["stripe.com", "adyen.com"], + web_search=True, + ) ) result = job.wait() print(result.results) @@ -109,14 +116,18 @@ print(result.results) Size a segment before pulling it: ```python -total = client.count(phrase_match=["book a demo"], country=["US"]) +from discolike.requests import CountParams + +total = client.count(CountParams(phrase_match=["book a demo"], country=["US"])) print(total.count) ``` Pull a full company profile: ```python -profile = client.companies.data(domain="stripe.com") +from discolike.requests import CompaniesDataParams + +profile = client.companies.data(CompaniesDataParams(domain="stripe.com")) ``` The client is a context manager if you want deterministic cleanup: @@ -133,10 +144,11 @@ Every resource has an async twin on `AsyncDiscolike`: ```python import asyncio from discolike import AsyncDiscolike +from discolike.requests import DiscoverParams async def main() -> None: async with AsyncDiscolike() as client: - companies = await client.discover(icp_text="B2B SaaS for logistics", max_records=10) + companies = await client.discover(DiscoverParams(icp_text="B2B SaaS for logistics", max_records=10)) print([c.domain for c in companies]) asyncio.run(main()) @@ -192,11 +204,12 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap | `client.contacts` | Search, look up, match, and discover contacts at target companies | | `client.match` | Match company names (plus phone/city/state) to domains — single or bulk CSV | | `client.append()` | Enrich a CSV of domains with DiscoLike datasets | -| `client.segment()` | Auto-segment a list of domains | +| `client.segment()` / `client.segment_file()` | Auto-segment a list of domains (comma-separated string or CSV upload) | | `client.validate_icp()` | Validate a domain list against an ICP definition | | `client.queries` | Saved inclusion/exclusion lists for reusable targeting | | `client.search_providers` / `client.llm_providers` | Manage BYOK search and LLM provider integrations for DiscoGen | | `client.account` | Usage and quota | +| `discolike.requests` | Request models for every call — generated from the platform OpenAPI spec, validated locally before the request is sent | All responses are typed [Pydantic](https://docs.pydantic.dev/) models. @@ -205,7 +218,9 @@ All responses are typed [Pydantic](https://docs.pydantic.dev/) models. Bulk operations (`match.bulk`, `segment`, `validate_icp`, `contacts.bulk_match`) return a `Job` handle instead of blocking: ```python -job = client.segment(domains=["stripe.com", "adyen.com", "checkout.com"]) +from discolike.requests import SegmentParams + +job = client.segment(SegmentParams(domains="stripe.com,adyen.com,checkout.com")) result = job.wait() ``` @@ -230,6 +245,18 @@ except ValidationError as err: `AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. Transient failures are retried automatically (3 attempts by default). +```python +import pydantic +from discolike.requests import MatchCompanyParams + +try: + params = MatchCompanyParams(name="Acme", min_match_confidence=10) +except pydantic.ValidationError as err: + ... # raised locally: min_match_confidence must be 50-100 +``` + +Request models validate before anything is sent, so a bad enum value or out-of-range number never costs a round trip. Unknown fields pass through untouched. + ### Configuration | Option | Default | | @@ -251,6 +278,8 @@ uv sync --all-packages uv run pytest packages/discolike/tests uv run pytest packages/discolike-cli/tests uv run ruff check . +uv run python scripts/gen_requests.py --spec-url https://api.dev.discolike.com/v1/openapi.json # regenerate request models +uv run python scripts/gen_requests.py --check # fail on drift (CI) ``` ## Support & contact diff --git a/examples/discover_and_enrich.py b/examples/discover_and_enrich.py index e86b4cf..6cdff84 100644 --- a/examples/discover_and_enrich.py +++ b/examples/discover_and_enrich.py @@ -2,9 +2,9 @@ Two calls end to end: -1. ``client.discover(icp_text=..., country=..., max_records=...)`` finds - lookalike companies from DiscoLike's index of 80M+ business websites. -2. ``client.discogen.process(query=..., domains=[...], web_search=True)`` +1. ``client.discover(DiscoverParams(icp_text=..., country=..., max_records=...))`` + finds lookalike companies from DiscoLike's index of 80M+ business websites. +2. ``client.discogen.process(DiscoGenProcessRequest(query=..., domains=[...], web_search=True))`` runs an AI research prompt over the discovered domains and returns one structured answer per company. ``job.wait()`` blocks until it finishes. @@ -26,6 +26,8 @@ import sys from discolike import Discolike +from discolike.requests import DiscoGenProcessRequest +from discolike.requests import DiscoverParams def parse_args() -> argparse.Namespace: @@ -43,7 +45,7 @@ def main() -> None: client = Discolike() print(f"Discovering up to {args.max_records} companies for: {args.icp!r}") - companies = client.discover(icp_text=args.icp, country=args.country, max_records=args.max_records) + companies = client.discover(DiscoverParams(icp_text=args.icp, country=args.country, max_records=args.max_records)) if not companies: sys.exit("No companies found for that ICP - try broadening it.") domains = [company.domain for company in companies if company.domain] @@ -51,7 +53,7 @@ def main() -> None: print(f" {company.domain} {company.name or ''} (similarity {company.similarity})") print(f"\nRunning DiscoGen over {len(domains)} domains: {args.query!r}") - job = client.discogen.process(query=args.query, domains=domains, web_search=True) + job = client.discogen.process(DiscoGenProcessRequest(query=args.query, domains=domains, web_search=True)) status = job.wait(timeout=args.timeout, on_poll=lambda s: print(f" status={s.status} progress={s.progress}%")) print("\nEnriched results:") diff --git a/examples/find_emails_from_csv.py b/examples/find_emails_from_csv.py index 6924d5b..4f498fb 100644 --- a/examples/find_emails_from_csv.py +++ b/examples/find_emails_from_csv.py @@ -2,7 +2,7 @@ Reads people from a CSV with ``first_name``, ``last_name``, and ``domain`` columns (names configurable), submits them with -``client.email.find_batch(contacts=[...])`` in chunks of up to 500, waits for +``client.email.find_batch(FindEmailBatchRequest(...))`` in chunks of up to 500, waits for each batch with ``batch.results()``, and writes the found emails plus status to an output CSV. @@ -22,6 +22,7 @@ from pathlib import Path from discolike import Discolike +from discolike.requests import FindEmailBatchRequest MAX_CONTACTS_PER_BATCH = 500 @@ -65,7 +66,7 @@ def main() -> None: for start in range(0, len(contacts), MAX_CONTACTS_PER_BATCH): chunk = contacts[start : start + MAX_CONTACTS_PER_BATCH] print(f"Submitting batch of {len(chunk)} contacts ({start + len(chunk)}/{len(contacts)})...") - batch = client.email.find_batch(contacts=chunk) + batch = client.email.find_batch(FindEmailBatchRequest.model_validate({"requests": chunk})) results = batch.results(timeout=args.timeout) for item in results.results: output = item.result diff --git a/examples/match_crm_contacts.py b/examples/match_crm_contacts.py index 457b8d4..55fa8e4 100644 --- a/examples/match_crm_contacts.py +++ b/examples/match_crm_contacts.py @@ -1,7 +1,7 @@ """Match a CSV of CRM contacts to DiscoLike persona IDs via bulk-match. Reads contacts from a CSV (column names are configurable), submits them to -``client.contacts.bulk_match()`` in chunks, and writes an output CSV with the +``client.contacts.bulk_match(BulkContactMatchRequest(...))`` in chunks, and writes an output CSV with the matched ``persona_id`` and ``match_score`` per row. Lessons baked in from production runs: @@ -34,6 +34,7 @@ from urllib.parse import urlparse from discolike import Discolike +from discolike.requests import BulkContactMatchRequest MAX_QUERIES_PER_CALL = 500 # bulk_match accepts 1-500 queries per request @@ -204,7 +205,9 @@ def main() -> None: hits: dict[int, dict[str, Any] | None] = dict.fromkeys(row_ids) else: try: - job = client.contacts.bulk_match(queries=queries, limit=1) + job = client.contacts.bulk_match( + BulkContactMatchRequest.model_validate({"queries": queries, "limit": 1}) + ) status = job.wait(timeout=args.timeout) except Exception as exc: # leave the chunk un-checkpointed so a rerun retries it failed_chunks += 1 diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index fa97293..acb5f6f 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "discolike-cli" -version = "0.2.0" +version = "0.3.0" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" @@ -12,7 +12,7 @@ license-files = ["LICENSE"] requires-python = ">=3.10" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ - "discolike==0.2.0", + "discolike==0.3.0", "typer>=0.12", "rich>=13.0", ] diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 12ece5e..8458d1f 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -24,13 +24,16 @@ Create a key at [app.discolike.com/account/management/keys](https://app.discolik ```python from discolike import Discolike +from discolike.requests import DiscoverParams client = Discolike() companies = client.discover( - icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", - country=["US"], - max_records=25, + DiscoverParams( + icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", + country=["US"], + max_records=25, + ) ) for company in companies: print(company.domain, company.name, company.similarity) @@ -50,10 +53,11 @@ Every resource has an async twin on `AsyncDiscolike`: ```python import asyncio from discolike import AsyncDiscolike +from discolike.requests import DiscoverParams async def main() -> None: async with AsyncDiscolike() as client: - companies = await client.discover(icp_text="B2B SaaS for logistics", max_records=10) + companies = await client.discover(DiscoverParams(icp_text="B2B SaaS for logistics", max_records=10)) print([c.domain for c in companies]) asyncio.run(main()) @@ -64,7 +68,9 @@ asyncio.run(main()) Bulk operations (`match.bulk`, `segment`, `validate_icp`, `contacts.bulk_match`) return a `Job` handle instead of blocking: ```python -job = client.segment(domains=["stripe.com", "adyen.com", "checkout.com"]) +from discolike.requests import SegmentParams + +job = client.segment(SegmentParams(domains="stripe.com,adyen.com,checkout.com")) result = job.wait() ``` diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 83c6ac5..cb245d2 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.2.0"] +cli = ["discolike-cli==0.3.0"] [project.urls] Homepage = "https://www.discolike.com" diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index d3ec452..493f741 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index 107096e..cd97cc1 100644 --- a/packages/discolike/tests/test_package.py +++ b/packages/discolike/tests/test_package.py @@ -2,4 +2,4 @@ def test_version() -> None: - assert discolike.__version__ == "0.2.0" + assert discolike.__version__ == "0.3.0" diff --git a/uv.lock b/uv.lock index 2ae6af7..2de2feb 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.2.0" +version = "0.3.0" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" }, From 2c00fc3d9e2b323858c69da5610671bfa79d2ff2 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 13:41:49 -0700 Subject: [PATCH 22/28] fix(docs): convert stale discover() call in README error-handling example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in the 0.3.0 doc sweep — discover() now takes DiscoverParams, not icp_text= directly. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 49a6b09..a72e0af 100644 --- a/README.md +++ b/README.md @@ -234,9 +234,10 @@ All errors inherit from `DiscolikeError`: ```python from discolike import Discolike, RateLimitError, ValidationError +from discolike.requests import DiscoverParams try: - companies = Discolike().discover(icp_text="fintech infrastructure") + companies = Discolike().discover(DiscoverParams(icp_text="fintech infrastructure")) except RateLimitError as err: ... except ValidationError as err: From ec6e8dc85e258476dcebadb96092b7f04c6eaf1a Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 14:20:43 -0700 Subject: [PATCH 23/28] docs: document append --dataset requirement, segment query_id wire change, and dev-spec drift check CHANGELOG was silent about two 0.3.0 behavior changes callers will hit: append now requires --dataset client-side, and GET /segment sends query_id as repeated params instead of comma-joined. README/CONTRIBUTING didn't explain that gen_requests.py --check (like check_contract.py) only passes against the dev spec until the platform deploys prod. --- CHANGELOG.md | 3 ++- CONTRIBUTING.md | 6 ++++++ README.md | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5696352..c371bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,10 @@ ``` - SDK (breaking): `segment` is split into `client.segment(SegmentParams)` (`GET /segment`, `domains` is the comma-separated string the API takes) and `client.segment_file(SegmentFileParams, file=...)` (`POST /segment`). `email.find_batch` drops its `contacts=` alias for the platform's `requests` field. `append` requires `dataset`, matching the platform. +- SDK (breaking): `GET /segment` sends `query_id` as repeated `query_id=` params (the spec declares it an array) instead of the old comma-joined single value. - SDK: deprecated parameters (`nl_match`, `min_score`, `negate_domain`, `exact_match`, `vendor`, `revenue_range` on contacts, `negate_icp_text`) are not part of the generated models; they still pass through as extra fields if set explicitly. - SDK: new `DiscolikeRequest` base (`discolike.DiscolikeRequest`) with `to_wire()`, which sends exactly the fields you set — an explicit `None` goes out as `null` (this is how `llm_providers.update` keeps the stored API key), and unset fields are omitted so server defaults keep governing. -- CLI: request models are built from the same options as before, so no flags change. Two behavior changes: `--param KEY=VALUE` with an unknown key is forwarded to the API (previously exit 2), and any option or `--param` value outside the spec (an unknown `--department`, `--max-records` below the floor, `--match loose`) exits 2 with `{"error": "ValidationError", ...}` on stderr before the request is sent. +- CLI: request models are built from the same options as before, so no flags change. Two behavior changes: `--param KEY=VALUE` with an unknown key is forwarded to the API (previously exit 2), and any option or `--param` value outside the spec (an unknown `--department`, `--max-records` below the floor, `--match loose`) exits 2 with `{"error": "ValidationError", ...}` on stderr before the request is sent. `discolike append` without `--dataset` now fails this same client-side validation (was optional before; `AppendParams` requires it). - CI: the contract job also runs `scripts/gen_requests.py --check`, so the committed models fail the build when the platform spec moves. ## 0.2.0 (2026-08-21) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11a8c51..fba3e74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,12 @@ CI also validates SDK routes against the live DiscoLike OpenAPI spec (`scripts/check_contract.py`). PRs from forks are checked against the production spec. +CI also runs `scripts/gen_requests.py --check` against the dev spec +(`https://api.dev.discolike.com/v1/openapi.json`) — the committed request +models in `discolike.requests` track dev, not prod. The prod spec lags, so +both `check_contract.py` and `gen_requests.py --check` against prod stay red +until the platform deploys; don't regenerate against prod to "fix" it. + ## Reporting bugs Open a GitHub issue with the package name, version, and a minimal diff --git a/README.md b/README.md index a72e0af..20e6eac 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,8 @@ uv run python scripts/gen_requests.py --spec-url https://api.dev.discolike.com/v uv run python scripts/gen_requests.py --check # fail on drift (CI) ``` +Committed request models track the dev spec (`--spec-url https://api.dev.discolike.com/v1/openapi.json`); the prod spec lags behind, so `--check` without a `--spec-url` (or against prod) stays red until the platform deploys — don't regenerate against prod to "fix" it. + ## Support & contact - **API documentation**: [docs.discolike.com](https://docs.discolike.com) From 54197a2bf6d901606fceeb75889e465089a80803 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 14:20:48 -0700 Subject: [PATCH 24/28] fix(gen_requests): silence ruff --fix noise and handle a missing generated file --check was leaking ruff's "Fixed N errors" lines into its own stdout because the repo sets show-fixes=true; pass --no-show-fixes explicitly. Also, importing discolike.resources at module scope meant deleting the committed requests.py crashed with a traceback instead of the intended "stale; run: ..." message, since check() never got a chance to guard for it. Deferred those imports into the functions that need them so a missing file is reported cleanly. --- scripts/gen_requests.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 976dd0d..4fae064 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -17,9 +17,6 @@ import httpx2 -import discolike.resources -from discolike.resources._base import get_discolike_route - SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -69,6 +66,8 @@ class Route: def _resource_modules() -> list[ModuleType]: + import discolike.resources + return [ importlib.import_module(info.name) for info in pkgutil.walk_packages(discolike.resources.__path__, prefix=f"{discolike.resources.__name__}.") @@ -76,6 +75,8 @@ def _resource_modules() -> list[ModuleType]: def collect_routes() -> list[Route]: + from discolike.resources._base import get_discolike_route + routes: dict[tuple[str, str], Route] = {} for module in _resource_modules(): for class_name, cls in inspect.getmembers(module, inspect.isclass): @@ -220,7 +221,9 @@ def generate(*, spec: dict[str, Any], output: pathlib.Path) -> None: check=True, ) ruff = [sys.executable, "-m", "ruff"] - subprocess.run([*ruff, "check", "--fix", "--quiet", "--config", str(RUFF_CONFIG), str(output)], check=True) # noqa: S603 + subprocess.run( # noqa: S603 + [*ruff, "check", "--fix", "--quiet", "--no-show-fixes", "--config", str(RUFF_CONFIG), str(output)], check=True + ) subprocess.run([*ruff, "format", "--quiet", "--config", str(RUFF_CONFIG), str(output)], check=True) # noqa: S603 @@ -242,6 +245,10 @@ def compare(*, committed: str, fresh: str) -> int: def check(*, spec: dict[str, Any]) -> int: + if not OUTPUT_PATH.exists(): + relative = OUTPUT_PATH.relative_to(REPO_ROOT) + print(f"{relative} is stale; run: uv run python scripts/gen_requests.py") + return 1 with tempfile.TemporaryDirectory() as tmp: candidate = pathlib.Path(tmp) / OUTPUT_PATH.name generate(spec=spec, output=candidate) From f244d966fdb766b1c61134ecaf0f55135f357fe6 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 14:20:58 -0700 Subject: [PATCH 25/28] test: retarget email-batch limit test at the model, fix stale noqa and missing annotations test_email_find_batch_rejects_more_than_500_before_any_request duplicated the CLI test above it without exercising the guard its name claimed (MAX_BATCH_CONTACTS fires first). Retargeted it as a direct FindEmailBatchRequest.model_validate test. Also: discover.py's ANN401 noqa said the kwargs are forwarded to typed resource/client methods, but they go to build_request as a dict; fixed the comment. Added -> None return annotations to test_gen_requests.py and test_requests_module.py to match the rest of the suite, and dropped an unused routes fixture arg. --- .../src/discolike_cli/discover.py | 2 +- .../discolike-cli/tests/test_email_cli.py | 14 ++++----- packages/discolike/tests/test_gen_requests.py | 30 +++++++++---------- .../discolike/tests/test_requests_module.py | 10 +++---- 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 4719ada..beafaeb 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -26,7 +26,7 @@ def _parse_param(raw: str) -> tuple[str, str | list[str]]: return key, value -def _merge_params(param: list[str] | None, **options: Any) -> dict[str, Any]: # noqa: ANN401 -- forwarded as **kwargs to typed resource/client methods +def _merge_params(param: list[str] | None, **options: Any) -> dict[str, Any]: # noqa: ANN401 -- forwarded as a dict to build_request kwargs: dict[str, Any] = dict(_parse_param(raw) for raw in param or []) kwargs.update({key: value for key, value in options.items() if value is not None}) return kwargs diff --git a/packages/discolike-cli/tests/test_email_cli.py b/packages/discolike-cli/tests/test_email_cli.py index 37dfc21..d9254c3 100644 --- a/packages/discolike-cli/tests/test_email_cli.py +++ b/packages/discolike-cli/tests/test_email_cli.py @@ -6,9 +6,11 @@ from collections.abc import Callable import httpx2 +import pydantic import pytest from typer.testing import CliRunner +from discolike.requests import FindEmailBatchRequest from discolike_cli.main import app from discolike_testkit import Handler @@ -200,14 +202,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert "500" in result.output -def test_email_find_batch_rejects_more_than_500_before_any_request( - tmp_path, install_build_client: Callable[[Handler], None] -) -> None: - contacts_file = tmp_path / "contacts.csv" - contacts_file.write_text("first_name,last_name,domain\n" + "Jane,Doe,acme.com\n" * 501) - install_build_client(lambda request: httpx2.Response(200, json={"batch_id": "eb-x"})) - result = runner.invoke(app, ["email", "find-batch", "--contacts-file", str(contacts_file)]) - assert result.exit_code == 2 +def test_find_email_batch_request_rejects_more_than_500_requests() -> None: + requests = [{"first_name": "Jane", "last_name": "Doe", "domain": "acme.com"}] * 501 + with pytest.raises(pydantic.ValidationError): + FindEmailBatchRequest.model_validate({"requests": requests}) def test_email_results_without_wait_returns_partial_snapshot( diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 5211aa1..26d5421 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -130,11 +130,11 @@ def routes(gen): ("ValidateResource", "icp", "IcpParams"), ], ) -def test_params_model_name(gen, class_name, method_name, expected): +def test_params_model_name(gen, class_name, method_name, expected) -> None: assert gen.params_model_name(class_name=class_name, method_name=method_name) == expected -def test_request_schemas_synthesizes_params_from_query_parameters(gen, routes): +def test_request_schemas_synthesizes_params_from_query_parameters(gen, routes) -> None: schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) assert schemas["MatchCompanyParams"] == { "type": "object", @@ -143,7 +143,7 @@ def test_request_schemas_synthesizes_params_from_query_parameters(gen, routes): } -def test_request_schemas_skips_multipart_body_and_keeps_query_params(gen, routes): +def test_request_schemas_skips_multipart_body_and_keeps_query_params(gen, routes) -> None: schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) assert schemas["MatchBulkParams"] == { "type": "object", @@ -153,34 +153,34 @@ def test_request_schemas_skips_multipart_body_and_keeps_query_params(gen, routes assert "Body_bulk_match" not in schemas -def test_request_schemas_uses_the_json_body_component_name(gen, routes): +def test_request_schemas_uses_the_json_body_component_name(gen, routes) -> None: schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) assert schemas["FindEmailBatchRequest"] == FAKE_SPEC["components"]["schemas"]["FindEmailBatchRequest"] -def test_request_schemas_emits_nothing_for_routes_without_params(gen, routes): +def test_request_schemas_emits_nothing_for_routes_without_params(gen, routes) -> None: schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) assert "AccountUsageParams" not in schemas -def test_request_schemas_fails_loudly_when_a_route_is_missing_from_the_spec(gen): +def test_request_schemas_fails_loudly_when_a_route_is_missing_from_the_spec(gen) -> None: with pytest.raises(SystemExit, match="GET /nowhere"): gen.request_schemas(spec=FAKE_SPEC, routes=[gen.Route("X", "y", "GET", "/nowhere")]) -def test_prune_keeps_transitive_refs_and_drops_unrelated_schemas(gen, routes): +def test_prune_keeps_transitive_refs_and_drops_unrelated_schemas(gen) -> None: requested = {"FindEmailBatchRequest": FAKE_SPEC["components"]["schemas"]["FindEmailBatchRequest"]} assert set(gen.prune(spec=FAKE_SPEC, requested=requested)) == {"FindEmailBatchRequest", "FindEmailRequest"} -def test_prune_orders_sibling_refs_deterministically(gen): +def test_prune_orders_sibling_refs_deterministically(gen) -> None: requested = {"Root": SIBLING_REF_SPEC["components"]["schemas"]["Root"]} kept = list(gen.prune(spec=SIBLING_REF_SPEC, requested=requested)) assert kept == ["Root", "Alpha", "Middle", "Zebra"] assert kept == list(gen.prune(spec=SIBLING_REF_SPEC, requested=requested)) -def test_normalize_schema_inlines_nullable_and_drops_deprecated_and_additional_properties(gen): +def test_normalize_schema_inlines_nullable_and_drops_deprecated_and_additional_properties(gen) -> None: normalized = gen.normalize_schema(FAKE_SPEC["components"]["schemas"]["FindEmailRequest"]) assert "additionalProperties" not in normalized assert "legacy" not in normalized["properties"] @@ -193,7 +193,7 @@ def test_normalize_schema_inlines_nullable_and_drops_deprecated_and_additional_p assert normalized["required"] == ["first_name"] -def test_normalize_schema_leaves_required_nullable_unions_alone(gen): +def test_normalize_schema_leaves_required_nullable_unions_alone(gen) -> None: schema = { "type": "object", "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, @@ -202,7 +202,7 @@ def test_normalize_schema_leaves_required_nullable_unions_alone(gen): assert gen.normalize_schema(schema)["properties"]["api_key"] == {"anyOf": [{"type": "string"}, {"type": "null"}]} -def test_normalize_schema_strips_scalar_item_constraints(gen): +def test_normalize_schema_strips_scalar_item_constraints(gen) -> None: schema = { "type": "object", "properties": {"tags": {"type": "array", "items": {"type": "string", "minLength": 3}, "maxItems": 20}}, @@ -214,7 +214,7 @@ def test_normalize_schema_strips_scalar_item_constraints(gen): } -def test_build_codegen_spec_wraps_pruned_schemas(gen, routes): +def test_build_codegen_spec_wraps_pruned_schemas(gen, routes) -> None: codegen_spec = gen.build_codegen_spec(spec=FAKE_SPEC, routes=routes) assert codegen_spec["paths"] == {} assert set(codegen_spec["components"]["schemas"]) == { @@ -225,12 +225,12 @@ def test_build_codegen_spec_wraps_pruned_schemas(gen, routes): } -def test_compare_returns_zero_when_identical(gen, capsys): +def test_compare_returns_zero_when_identical(gen, capsys) -> None: assert gen.compare(committed="a\n", fresh="a\n") == 0 assert "up to date" in capsys.readouterr().out -def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys): +def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys) -> None: assert gen.compare(committed="a\n", fresh="b\n") == 1 out = capsys.readouterr().out assert "-a" in out @@ -238,7 +238,7 @@ def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys): assert "gen_requests.py" in out -def test_collect_routes_covers_every_stamped_sync_route(gen): +def test_collect_routes_covers_every_stamped_sync_route(gen) -> None: routes = gen.collect_routes() assert len(routes) == 48 assert all(not route.class_name.startswith("Async") for route in routes) diff --git a/packages/discolike/tests/test_requests_module.py b/packages/discolike/tests/test_requests_module.py index dc59da7..c40db90 100644 --- a/packages/discolike/tests/test_requests_module.py +++ b/packages/discolike/tests/test_requests_module.py @@ -8,12 +8,12 @@ from discolike.requests import MatchCompanyParams -def test_every_exported_name_is_a_request_model(): +def test_every_exported_name_is_a_request_model() -> None: for name in requests_module.__all__: assert issubclass(getattr(requests_module, name), DiscolikeRequest), name -def test_all_is_sorted_and_complete(): +def test_all_is_sorted_and_complete() -> None: public = sorted( name for name, value in vars(requests_module).items() @@ -22,19 +22,19 @@ def test_all_is_sorted_and_complete(): assert list(requests_module.__all__) == public -def test_constraint_violations_fail_before_any_request(): +def test_constraint_violations_fail_before_any_request() -> None: with pytest.raises(pydantic.ValidationError, match="min_similarity"): DiscoverParams(min_similarity=200) with pytest.raises(pydantic.ValidationError, match="name"): MatchCompanyParams.model_validate({}) -def test_required_nullable_field_survives_generation(): +def test_required_nullable_field_survives_generation() -> None: request = LLMProviderUpdateRequest(integration_name="n", provider="p", model_name="m", api_key=None) assert request.to_wire()["api_key"] is None with pytest.raises(pydantic.ValidationError, match="api_key"): LLMProviderUpdateRequest.model_validate({"integration_name": "n", "provider": "p", "model_name": "m"}) -def test_models_allow_extra_fields(): +def test_models_allow_extra_fields() -> None: assert MatchCompanyParams.model_validate({"name": "Acme", "future_flag": 1}).to_wire()["future_flag"] == 1 From c649548f44e74ce62c64ef1714e1b75eda9462a5 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 14:23:28 -0700 Subject: [PATCH 26/28] revert gen_requests missing-file guard: keep imports at module top The deferred-import workaround for a deleted committed requests.py traded a stale repo convention violation for a marginal UX improvement. A missing _generated/requests.py means a broken checkout; the ModuleNotFoundError is the honest failure and imports belong at module scope. Keeps the --no-show-fixes fix from the same pass. --- scripts/gen_requests.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 4fae064..26b8fcd 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -17,6 +17,9 @@ import httpx2 +import discolike.resources +from discolike.resources._base import get_discolike_route + SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -66,8 +69,6 @@ class Route: def _resource_modules() -> list[ModuleType]: - import discolike.resources - return [ importlib.import_module(info.name) for info in pkgutil.walk_packages(discolike.resources.__path__, prefix=f"{discolike.resources.__name__}.") @@ -75,8 +76,6 @@ def _resource_modules() -> list[ModuleType]: def collect_routes() -> list[Route]: - from discolike.resources._base import get_discolike_route - routes: dict[tuple[str, str], Route] = {} for module in _resource_modules(): for class_name, cls in inspect.getmembers(module, inspect.isclass): @@ -245,10 +244,6 @@ def compare(*, committed: str, fresh: str) -> int: def check(*, spec: dict[str, Any]) -> int: - if not OUTPUT_PATH.exists(): - relative = OUTPUT_PATH.relative_to(REPO_ROOT) - print(f"{relative} is stale; run: uv run python scripts/gen_requests.py") - return 1 with tempfile.TemporaryDirectory() as tmp: candidate = pathlib.Path(tmp) / OUTPUT_PATH.name generate(spec=spec, output=candidate) From 3b6762e4ed6bcc511f87a3465e1cfea5ad1dc4a4 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 14:33:28 -0700 Subject: [PATCH 27/28] fix(examples): narrow batch result to EnumerationOutput before reading identity fields EmailJobResult.result is EnumerationOutput | ValidationOutput | None because the model is shared by find and verify batches. The example read find-only fields off the union, which ty rejects; isinstance narrowing replaces the getattr workaround. --- examples/find_emails_from_csv.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/find_emails_from_csv.py b/examples/find_emails_from_csv.py index 4f498fb..228ca6b 100644 --- a/examples/find_emails_from_csv.py +++ b/examples/find_emails_from_csv.py @@ -22,6 +22,7 @@ from pathlib import Path from discolike import Discolike +from discolike import EnumerationOutput from discolike.requests import FindEmailBatchRequest MAX_CONTACTS_PER_BATCH = 500 @@ -70,7 +71,7 @@ def main() -> None: results = batch.results(timeout=args.timeout) for item in results.results: output = item.result - if output is None: + if not isinstance(output, EnumerationOutput): # Failed jobs carry no EnumerationOutput (so no identity), # but must not vanish from the output: keep the status and # error so the failure is visible and countable. @@ -86,8 +87,7 @@ def main() -> None: } ) continue - match = getattr(output, "result", None) - email = match.email if match is not None else None + email = output.result.email if output.result is not None else None if output.status == "found": found += 1 writer.writerow( From a8de867bb6093145e25231e0d87f4b29cad54c29 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 14:39:50 -0700 Subject: [PATCH 28/28] fix(cli): reject empty cells in --contacts-file before submitting --contact already rejects blank parts locally, but a CSV row with an empty first_name/last_name/domain was blanked to "" and sent to the API, which rejected it server-side. The spec puts no min_length on those fields, so the generated model cannot catch it; the CSV reader has to. --- .../discolike-cli/src/discolike_cli/email.py | 10 +++++++++- .../discolike-cli/tests/test_email_cli.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/discolike-cli/src/discolike_cli/email.py b/packages/discolike-cli/src/discolike_cli/email.py index 3786bac..c591f7f 100644 --- a/packages/discolike-cli/src/discolike_cli/email.py +++ b/packages/discolike-cli/src/discolike_cli/email.py @@ -21,6 +21,7 @@ MAX_BATCH_CONTACTS = 500 EMAIL_KINDS = ("find", "verify") CSV_COLUMNS = ("first_name", "last_name", "domain") +FIRST_DATA_ROW = 2 FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." WAIT_HELP = "Block until the job finishes, streaming progress to stderr." @@ -56,7 +57,14 @@ def _read_contacts_file(contacts_file: pathlib.Path) -> list[dict[str, str]]: missing = set(CSV_COLUMNS) - set(reader.fieldnames or []) if missing: raise typer.BadParameter(f"--contacts-file is missing required CSV columns: {', '.join(sorted(missing))}") - return [{column: (row.get(column) or "").strip() for column in CSV_COLUMNS} for row in reader] + contacts = [] + for row_number, row in enumerate(reader, start=FIRST_DATA_ROW): + contact = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS} + empty = [column for column, value in contact.items() if not value] + if empty: + raise typer.BadParameter(f"--contacts-file row {row_number} has empty {', '.join(empty)}") + contacts.append(contact) + return contacts def _fetch_batch_snapshot(batch: EmailBatch) -> EmailBatchResults: diff --git a/packages/discolike-cli/tests/test_email_cli.py b/packages/discolike-cli/tests/test_email_cli.py index d9254c3..bb039eb 100644 --- a/packages/discolike-cli/tests/test_email_cli.py +++ b/packages/discolike-cli/tests/test_email_cli.py @@ -186,6 +186,25 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert "domain" in result.output +def test_email_find_batch_rejects_empty_csv_cell_before_any_request( + tmp_path: pathlib.Path, install_build_client: Callable[[Handler], None] +) -> None: + contacts_file = tmp_path / "contacts.csv" + contacts_file.write_text("first_name,last_name,domain\nJane,Doe,acme.com\nJohn,Smith,\n") + calls: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(request) + return httpx2.Response(200, json={"batch_id": "eb-5"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find-batch", "--contacts-file", str(contacts_file)]) + assert result.exit_code == 2 + assert "row 3" in result.output + assert "domain" in result.output + assert calls == [] + + def test_email_find_batch_over_500_contacts_exits_2( tmp_path: pathlib.Path, install_build_client: Callable[[Handler], None] ) -> None: