From 3c11530143229bca154eb51751948c682621d179 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 27 Aug 2026 12:15:48 -0700 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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: From 1a55ddc4177734a299404efdc9621998c1a3064f Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Thu, 27 Aug 2026 19:11:41 -0700 Subject: [PATCH 29/44] feat(sdk): expose estimated_cost and cost_metadata on JobStatus DiscoGen-family status responses carry per-model usage plus, since the platform started reporting it, a search_provider entry with the BYOS query count and cost. Without typed fields callers had to dig them out of to_dict(). search_calls on the model entries only counts built-in search and reads 0 on every BYOS run, so the docs point readers at search_provider.queries_executed instead (ticket 900). --- CHANGELOG.md | 4 +++ README.md | 2 +- packages/discolike/README.md | 2 +- packages/discolike/src/discolike/_jobs.py | 8 ++++++ packages/discolike/tests/test_jobs.py | 33 +++++++++++++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c371bfd..5fa242e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened. + ## 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. diff --git a/README.md b/README.md index 20e6eac..89196de 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ job = client.segment(SegmentParams(domains="stripe.com,adyen.com,checkout.com")) result = job.wait() ``` -`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. +`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. On DiscoGen-family jobs the returned `JobStatus` also carries `warnings`, `estimated_cost` and `cost_metadata` (per-model usage plus a `search_provider` entry when a BYOS search provider ran; `search_calls` only counts the model's built-in search). `JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down. diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 8458d1f..e7d6aff 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -74,7 +74,7 @@ job = client.segment(SegmentParams(domains="stripe.com,adyen.com,checkout.com")) result = job.wait() ``` -`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. +`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. On DiscoGen-family jobs the returned `JobStatus` also carries `warnings`, `estimated_cost` and `cost_metadata` (per-model usage plus a `search_provider` entry when a BYOS search provider ran; `search_calls` only counts the model's built-in search). `JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down. diff --git a/packages/discolike/src/discolike/_jobs.py b/packages/discolike/src/discolike/_jobs.py index 81c2e1a..83d3c35 100644 --- a/packages/discolike/src/discolike/_jobs.py +++ b/packages/discolike/src/discolike/_jobs.py @@ -28,6 +28,14 @@ class JobStatus(DiscolikeModel): results: Any = None result: Any = None warnings: list[str] = pydantic.Field(default_factory=list) + # DiscoGen-family only: spend on the caller's own provider keys, best-effort. + # cost_metadata holds one entry per "provider/model" (calls, search_calls, + # prompt_tokens, completion_tokens, est_cost_usd) and a "search_provider" + # entry (provider, search_model, queries_executed, queries_succeeded, + # est_cost_usd) when a BYOS search provider ran. search_calls counts the + # model's built-in search only; on a BYOS run read search_provider instead. + estimated_cost: float | None = None + cost_metadata: dict[str, dict[str, Any]] | None = None class Job: diff --git a/packages/discolike/tests/test_jobs.py b/packages/discolike/tests/test_jobs.py index 5dd44c4..c9d81ab 100644 --- a/packages/discolike/tests/test_jobs.py +++ b/packages/discolike/tests/test_jobs.py @@ -54,6 +54,39 @@ def test_wait_polls_to_completion() -> None: assert final.results == [{"domain": "a.com"}] +def test_status_exposes_cost_metadata_and_warnings() -> None: + payload = { + "status": "completed", + "progress": 100, + "results": {"a.com": "yes"}, + "estimated_cost": 0.0283, + "warnings": ["Search provider out of credits"], + "cost_metadata": { + "openai/gpt-4o-mini": {"calls": 10, "search_calls": 0, "est_cost_usd": 0.0083}, + "search_provider": { + "provider": "serper", + "search_model": "serper/search", + "queries_executed": 20, + "queries_succeeded": 20, + "est_cost_usd": 0.02, + }, + }, + } + final = make_job(_status_sequence([payload])).status() + assert final.estimated_cost == 0.0283 + assert final.warnings == ["Search provider out of credits"] + assert final.cost_metadata is not None + assert final.cost_metadata["search_provider"]["queries_executed"] == 20 + assert final.cost_metadata["openai/gpt-4o-mini"]["search_calls"] == 0 + + +def test_status_without_cost_fields_defaults_to_none() -> None: + final = make_job(_status_sequence([{"status": "in_progress", "progress": 10}])).status() + assert final.estimated_cost is None + assert final.cost_metadata is None + assert final.warnings == [] + + def test_wait_failed_raises() -> None: handler = _status_sequence([{"status": "failed", "progress": 100, "result": "LLM exploded"}]) with pytest.raises(JobFailedError, match="LLM exploded"): From 3d7150616826f1788fb5f2c5d7c474848644ca9b Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 09:33:41 -0700 Subject: [PATCH 30/44] feat(sdk): OAuth credentials and bearer auth flow The REST API now accepts PropelAuth bearer tokens, so the SDK needs a credential that can outlive a one-hour access token. Auth moves from a static X-discolike-key header into an httpx2.Auth subclass that picks the header per credential, refreshes an OAuth token before expiry and once after a 401, and serialises concurrent refreshes so a burst of requests rotates the refresh token exactly once. Refreshes are yielded through the calling client's own transport rather than a second HTTP client, which keeps sync/async symmetric and lets MockTransport drive them in tests. Rotated tokens are written back only when the credential came from the config file; an injected auth= stays the caller's responsibility. Discolike(api_key=...), resolve_api_key, the config file location and the api_key JSON shape are unchanged; OAuth adds an "oauth" object under auth_method="oauth". Version 0.4.0. --- .../src/discolike_testkit/__init__.py | 9 +- packages/discolike/README.md | 2 +- packages/discolike/pyproject.toml | 2 +- packages/discolike/src/discolike/__init__.py | 4 + packages/discolike/src/discolike/_auth.py | 111 +++++++++ packages/discolike/src/discolike/_client.py | 17 +- packages/discolike/src/discolike/_config.py | 38 ++- .../discolike/src/discolike/_credentials.py | 40 ++++ packages/discolike/src/discolike/_oauth.py | 172 +++++++++++++ .../discolike/src/discolike/_transport.py | 14 +- packages/discolike/src/discolike/_version.py | 2 +- packages/discolike/tests/test_auth.py | 193 +++++++++++++++ packages/discolike/tests/test_client.py | 73 ++++++ packages/discolike/tests/test_config.py | 57 +++++ packages/discolike/tests/test_jobs.py | 5 +- packages/discolike/tests/test_oauth.py | 226 ++++++++++++++++++ packages/discolike/tests/test_package.py | 2 +- packages/discolike/tests/test_transport.py | 31 ++- pyproject.toml | 2 +- uv.lock | 2 +- 20 files changed, 974 insertions(+), 28 deletions(-) create mode 100644 packages/discolike/src/discolike/_auth.py create mode 100644 packages/discolike/src/discolike/_credentials.py create mode 100644 packages/discolike/src/discolike/_oauth.py create mode 100644 packages/discolike/tests/test_auth.py create mode 100644 packages/discolike/tests/test_oauth.py diff --git a/packages/discolike-testkit/src/discolike_testkit/__init__.py b/packages/discolike-testkit/src/discolike_testkit/__init__.py index c6a084d..8afa009 100644 --- a/packages/discolike-testkit/src/discolike_testkit/__init__.py +++ b/packages/discolike-testkit/src/discolike_testkit/__init__.py @@ -13,9 +13,16 @@ from discolike import AsyncDiscolike from discolike import Discolike +from discolike._auth import DiscolikeAuth +from discolike._credentials import ApiKeyCredential -__all__ = ["AsyncClientFactory", "ClientFactory", "Handler"] +__all__ = ["AsyncClientFactory", "ClientFactory", "Handler", "api_key_auth"] Handler = Callable[[httpx2.Request], httpx2.Response] ClientFactory = Callable[[Handler], Discolike] AsyncClientFactory = Callable[[Handler], AsyncDiscolike] + + +def api_key_auth(api_key: str) -> DiscolikeAuth: + """Auth for tests that build a ``Transport`` directly instead of going through ``Discolike``.""" + return DiscolikeAuth(ApiKeyCredential(api_key=api_key)) diff --git a/packages/discolike/README.md b/packages/discolike/README.md index e7d6aff..db8ef62 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -18,7 +18,7 @@ Requires Python 3.10+. export DISCOLIKE_API_KEY="dl_..." ``` -Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also pass `api_key=...` explicitly to `Discolike()`. +Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also pass `api_key=...` explicitly to `Discolike()`, or run `discolike auth login` from the CLI to log in through the browser — the SDK then picks up the saved OAuth session and refreshes it automatically. ## Quickstart diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index cb245d2..197da96 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.3.0"] +cli = ["discolike-cli==0.4.0"] [project.urls] Homepage = "https://www.discolike.com" diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index 7f4b96b..48419cd 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -1,5 +1,7 @@ from discolike._client import AsyncDiscolike from discolike._client import Discolike +from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthCredential from discolike._exceptions import APIConnectionError from discolike._exceptions import AuthenticationError from discolike._exceptions import DiscolikeError @@ -26,6 +28,7 @@ __all__ = [ "APIConnectionError", + "ApiKeyCredential", "AsyncDiscolike", "AsyncJob", "AuthenticationError", @@ -44,6 +47,7 @@ "JobStatus", "JobTimeoutError", "NotFoundError", + "OAuthCredential", "PlanAccessError", "RateLimitError", "ServerError", diff --git a/packages/discolike/src/discolike/_auth.py b/packages/discolike/src/discolike/_auth.py new file mode 100644 index 0000000..9578a60 --- /dev/null +++ b/packages/discolike/src/discolike/_auth.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import asyncio +import threading +from collections.abc import AsyncGenerator +from collections.abc import Callable +from collections.abc import Generator +from typing import cast + +import httpx2 + +from discolike._credentials import ApiKeyCredential +from discolike._credentials import Credential +from discolike._credentials import OAuthCredential +from discolike._exceptions import AuthenticationError +from discolike._oauth import REFRESH_LEEWAY_SECONDS +from discolike._oauth import SESSION_EXPIRED_MESSAGE +from discolike._oauth import parse_refresh_response +from discolike._oauth import refresh_request + +API_KEY_HEADER = "X-discolike-key" +UNAUTHORIZED = 401 + + +def _set_bearer(request: httpx2.Request, credential: OAuthCredential) -> None: + request.headers["Authorization"] = f"Bearer {credential.access_token}" + + +class DiscolikeAuth(httpx2.Auth): + """Sends the API key header, or a bearer token that is refreshed before expiry and once after a 401. + + Refreshes go through the same client as the request they precede, so tests drive them via ``MockTransport``. + """ + + requires_response_body = False + + def __init__(self, credential: Credential, *, on_update: Callable[[OAuthCredential], None] | None = None) -> None: + self._credential = credential + self.on_update = on_update + self._lock = threading.Lock() + self._async_lock = asyncio.Lock() + + @property + def credential(self) -> Credential: + return self._credential + + def _latest(self, credential: OAuthCredential) -> OAuthCredential: + return cast(OAuthCredential, self._credential) if self._credential is not credential else credential + + def _store(self, response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential: + try: + rotated = parse_refresh_response(response, credential=credential) + except AuthenticationError as exc: + raise AuthenticationError( + SESSION_EXPIRED_MESSAGE, status_code=exc.status_code, payload=exc.payload + ) from exc + self._credential = rotated + if self.on_update is not None: + self.on_update(rotated) + return rotated + + def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]: + credential = self._credential + if isinstance(credential, ApiKeyCredential): + request.headers[API_KEY_HEADER] = credential.api_key + yield request + return + with self._lock: + credential = self._latest(credential) + if credential.expires_within(REFRESH_LEEWAY_SECONDS): + credential = yield from self._sync_refresh(credential) + _set_bearer(request, credential) + response = yield request + if response.status_code != UNAUTHORIZED: + return + with self._lock: + latest = self._latest(credential) + if latest is credential: + latest = yield from self._sync_refresh(credential) + _set_bearer(request, latest) + yield request + + def _sync_refresh(self, credential: OAuthCredential) -> Generator[httpx2.Request, httpx2.Response, OAuthCredential]: + response = yield refresh_request(credential) + response.read() + return self._store(response, credential=credential) + + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + credential = self._credential + if isinstance(credential, ApiKeyCredential): + request.headers[API_KEY_HEADER] = credential.api_key + yield request + return + async with self._async_lock: + credential = self._latest(credential) + if credential.expires_within(REFRESH_LEEWAY_SECONDS): + response = yield refresh_request(credential) + await response.aread() + credential = self._store(response, credential=credential) + _set_bearer(request, credential) + response = yield request + if response.status_code != UNAUTHORIZED: + return + async with self._async_lock: + latest = self._latest(credential) + if latest is credential: + response = yield refresh_request(credential) + await response.aread() + latest = self._store(response, credential=credential) + _set_bearer(request, latest) + yield request diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index 623455e..1a4b660 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -2,8 +2,11 @@ import httpx2 +from discolike._auth import DiscolikeAuth from discolike._config import DEFAULT_BASE_URL -from discolike._config import resolve_api_key +from discolike._config import resolve_credential +from discolike._config import save_credential +from discolike._credentials import Credential from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._transport import AsyncTransport @@ -47,11 +50,18 @@ DEFAULT_MAX_RETRIES = 3 +def _build_auth(*, api_key: str | None, auth: Credential | None) -> DiscolikeAuth: + # Rotated refresh tokens are written back only when the credential came from the config file. + credential = resolve_credential(api_key=api_key, auth=auth) + return DiscolikeAuth(credential, on_update=save_credential if auth is None else None) + + class Discolike: def __init__( self, *, api_key: str | None = None, + auth: Credential | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT_SECONDS, max_retries: int = DEFAULT_MAX_RETRIES, @@ -59,7 +69,7 @@ def __init__( ) -> None: self._attach( Transport( - resolve_api_key(api_key), + _build_auth(api_key=api_key, auth=auth), base_url=base_url, timeout=timeout, max_retries=max_retries, @@ -121,6 +131,7 @@ def __init__( self, *, api_key: str | None = None, + auth: Credential | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT_SECONDS, max_retries: int = DEFAULT_MAX_RETRIES, @@ -128,7 +139,7 @@ def __init__( ) -> None: self._attach( AsyncTransport( - resolve_api_key(api_key), + _build_auth(api_key=api_key, auth=auth), base_url=base_url, timeout=timeout, max_retries=max_retries, diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index 65501d4..cca3c90 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -5,13 +5,18 @@ from pathlib import Path from typing import Any +from discolike._credentials import ApiKeyCredential +from discolike._credentials import Credential +from discolike._credentials import OAuthCredential from discolike._exceptions import AuthenticationError DEFAULT_BASE_URL = "https://api.discolike.com/v1" ENV_API_KEY = "DISCOLIKE_API_KEY" # foxguard: ignore[py/no-hardcoded-secret] KEYS_URL = "https://app.discolike.com/account/management/keys" +AUTH_METHOD_API_KEY = "api_key" +AUTH_METHOD_OAUTH = "oauth" -_NO_KEY_MESSAGE = ( +NO_CREDENTIAL_MESSAGE = ( "No API key found. Set the DISCOLIKE_API_KEY environment variable, pass api_key=..., " f"or run `discolike auth login`. Create a key at {KEYS_URL}" ) @@ -55,4 +60,33 @@ def resolve_api_key(explicit: str | None = None) -> str: from_file = load_config().get("api_key") if from_file: return str(from_file) - raise AuthenticationError(_NO_KEY_MESSAGE) + raise AuthenticationError(NO_CREDENTIAL_MESSAGE) + + +def load_credential() -> Credential | None: + config = load_config() + if config.get("auth_method") == AUTH_METHOD_OAUTH: + return OAuthCredential.from_config(config["oauth"]) + api_key = config.get("api_key") + return ApiKeyCredential(api_key=str(api_key)) if api_key else None + + +def save_credential(credential: Credential) -> None: + if isinstance(credential, OAuthCredential): + save_config({"auth_method": AUTH_METHOD_OAUTH, "oauth": credential.to_config()}) + return + save_config({"auth_method": AUTH_METHOD_API_KEY, "api_key": credential.api_key}) + + +def resolve_credential(*, api_key: str | None = None, auth: Credential | None = None) -> Credential: + if auth is not None: + return auth + if api_key: + return ApiKeyCredential(api_key=api_key) + from_env = os.environ.get(ENV_API_KEY) + if from_env: + return ApiKeyCredential(api_key=from_env) + credential = load_credential() + if credential is not None: + return credential + raise AuthenticationError(NO_CREDENTIAL_MESSAGE) diff --git a/packages/discolike/src/discolike/_credentials.py b/packages/discolike/src/discolike/_credentials.py new file mode 100644 index 0000000..3eed672 --- /dev/null +++ b/packages/discolike/src/discolike/_credentials.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import time +from dataclasses import asdict +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ApiKeyCredential: + api_key: str + + +@dataclass(frozen=True) +class OAuthCredential: + access_token: str + refresh_token: str + expires_at: float + client_id: str + token_endpoint: str + + def expires_within(self, seconds: float, *, now: float | None = None) -> bool: + current = time.time() if now is None else now + return self.expires_at - current <= seconds + + @classmethod + def from_config(cls, data: dict[str, Any]) -> OAuthCredential: + return cls( + access_token=str(data["access_token"]), + refresh_token=str(data["refresh_token"]), + expires_at=float(data["expires_at"]), + client_id=str(data["client_id"]), + token_endpoint=str(data["token_endpoint"]), + ) + + def to_config(self) -> dict[str, Any]: + return asdict(self) + + +Credential = ApiKeyCredential | OAuthCredential diff --git a/packages/discolike/src/discolike/_oauth.py b/packages/discolike/src/discolike/_oauth.py new file mode 100644 index 0000000..b8243a2 --- /dev/null +++ b/packages/discolike/src/discolike/_oauth.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import base64 +import hashlib +import secrets +import time +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +import httpx2 + +from discolike._credentials import OAuthCredential +from discolike._exceptions import AuthenticationError + +OAUTH_SCOPE = "offline_access" +REFRESH_LEEWAY_SECONDS = 60.0 +CLIENT_NAME = "discolike-cli" +METADATA_PATH = "/.well-known/oauth-authorization-server" +GRANT_TYPES = ["authorization_code", "refresh_token"] +RESPONSE_TYPES = ["code"] +PKCE_METHOD = "S256" +PKCE_VERIFIER_BYTES = 32 +TOKEN_HEADERS = {"Accept": "application/json"} +SESSION_EXPIRED_MESSAGE = "OAuth session expired; run `discolike auth login`" + + +@dataclass(frozen=True) +class AuthServerMetadata: + authorization_endpoint: str + token_endpoint: str + registration_endpoint: str + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def pkce_pair() -> tuple[str, str]: + verifier = _b64url(secrets.token_bytes(PKCE_VERIFIER_BYTES)) + return verifier, _b64url(hashlib.sha256(verifier.encode("ascii")).digest()) + + +def _payload(response: httpx2.Response) -> dict[str, Any]: + try: + payload = response.json() + except ValueError as exc: + raise AuthenticationError( + f"OAuth server returned a non-JSON response (HTTP {response.status_code})", status_code=response.status_code + ) from exc + if isinstance(payload, dict) and "error" in payload: + description = payload.get("error_description") + message = f"{payload['error']}: {description}" if description else str(payload["error"]) + raise AuthenticationError(message, status_code=response.status_code, payload=payload) + if response.status_code >= 400 or not isinstance(payload, dict): + raise AuthenticationError( + f"OAuth server returned HTTP {response.status_code}", status_code=response.status_code, payload=payload + ) + return payload + + +def _require(payload: dict[str, Any], key: str) -> str: + if key not in payload: + raise AuthenticationError(f"OAuth server response is missing `{key}`", payload=payload) + return str(payload[key]) + + +def _credential_from_token_payload( + payload: dict[str, Any], *, client_id: str, token_endpoint: str, fallback_refresh_token: str | None +) -> OAuthCredential: + refresh_token = payload.get("refresh_token") or fallback_refresh_token + if not refresh_token: + raise AuthenticationError("OAuth token response has no `refresh_token`", payload=payload) + return OAuthCredential( + access_token=_require(payload, "access_token"), + refresh_token=str(refresh_token), + expires_at=time.time() + float(_require(payload, "expires_in")), + client_id=client_id, + token_endpoint=token_endpoint, + ) + + +def discover(base_url: str, *, client: httpx2.Client) -> AuthServerMetadata: + payload = _payload(client.get(base_url.rstrip("/") + METADATA_PATH)) + return AuthServerMetadata( + authorization_endpoint=_require(payload, "authorization_endpoint"), + token_endpoint=_require(payload, "token_endpoint"), + registration_endpoint=_require(payload, "registration_endpoint"), + ) + + +def register_client(metadata: AuthServerMetadata, *, redirect_uris: list[str], client: httpx2.Client) -> str: + body = { + "client_name": CLIENT_NAME, + "redirect_uris": redirect_uris, + "grant_types": GRANT_TYPES, + "response_types": RESPONSE_TYPES, + "token_endpoint_auth_method": "none", + } + return _require(_payload(client.post(metadata.registration_endpoint, json=body)), "client_id") + + +def build_authorization_url( + metadata: AuthServerMetadata, + *, + client_id: str, + redirect_uri: str, + code_challenge: str, + state: str, + resource: str, + scope: str = OAUTH_SCOPE, +) -> str: + query = urlencode( + { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "code_challenge_method": PKCE_METHOD, + "state": state, + "resource": resource, + "scope": scope, + } + ) + separator = "&" if "?" in metadata.authorization_endpoint else "?" + return f"{metadata.authorization_endpoint}{separator}{query}" + + +def exchange_code( + metadata: AuthServerMetadata, + *, + client_id: str, + code: str, + code_verifier: str, + redirect_uri: str, + resource: str, + client: httpx2.Client, +) -> OAuthCredential: + form = { + "grant_type": "authorization_code", + "client_id": client_id, + "code": code, + "code_verifier": code_verifier, + "redirect_uri": redirect_uri, + "resource": resource, + } + response = client.post(metadata.token_endpoint, data=form, headers=TOKEN_HEADERS) + return _credential_from_token_payload( + _payload(response), client_id=client_id, token_endpoint=metadata.token_endpoint, fallback_refresh_token=None + ) + + +def refresh_request(credential: OAuthCredential) -> httpx2.Request: + form = { + "grant_type": "refresh_token", + "refresh_token": credential.refresh_token, + "client_id": credential.client_id, + } + return httpx2.Request("POST", credential.token_endpoint, data=form, headers=TOKEN_HEADERS) + + +def parse_refresh_response(response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential: + return _credential_from_token_payload( + _payload(response), + client_id=credential.client_id, + token_endpoint=credential.token_endpoint, + fallback_refresh_token=credential.refresh_token, + ) + + +def refresh(credential: OAuthCredential, *, client: httpx2.Client) -> OAuthCredential: + return parse_refresh_response(client.send(refresh_request(credential)), credential=credential) diff --git a/packages/discolike/src/discolike/_transport.py b/packages/discolike/src/discolike/_transport.py index 95296cd..0b54f57 100644 --- a/packages/discolike/src/discolike/_transport.py +++ b/packages/discolike/src/discolike/_transport.py @@ -22,8 +22,8 @@ def drop_none(params: Mapping[str, Any] | None) -> dict[str, Any]: return {key: value for key, value in (params or {}).items() if value is not None} -def _default_headers(api_key: str) -> dict[str, str]: - return {"X-discolike-key": api_key, "User-Agent": f"discolike-python/{__version__}"} +def _default_headers() -> dict[str, str]: + return {"User-Agent": f"discolike-python/{__version__}"} def _retryable_statuses(method: str) -> frozenset[int]: @@ -45,7 +45,7 @@ def _retry_delay(response: httpx2.Response | None, attempt: int) -> float: class Transport: def __init__( self, - api_key: str, + auth: httpx2.Auth, *, base_url: str, timeout: float, @@ -55,7 +55,8 @@ def __init__( if http_client is not None and not str(http_client.base_url): http_client.base_url = base_url self._client = http_client or httpx2.Client(base_url=base_url, timeout=timeout) - self._client.headers.update(_default_headers(api_key)) + self._client.auth = auth + self._client.headers.update(_default_headers()) self._max_retries = max_retries self._timeout_override: float | httpx2.Timeout | None = None self._is_view = False @@ -109,7 +110,7 @@ def close(self) -> None: class AsyncTransport: def __init__( self, - api_key: str, + auth: httpx2.Auth, *, base_url: str, timeout: float, @@ -119,7 +120,8 @@ def __init__( if http_client is not None and not str(http_client.base_url): http_client.base_url = base_url self._client = http_client or httpx2.AsyncClient(base_url=base_url, timeout=timeout) - self._client.headers.update(_default_headers(api_key)) + self._client.auth = auth + self._client.headers.update(_default_headers()) self._max_retries = max_retries self._timeout_override: float | httpx2.Timeout | None = None self._is_view = False diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index 493f741..6a9beea 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/packages/discolike/tests/test_auth.py b/packages/discolike/tests/test_auth.py new file mode 100644 index 0000000..9f9f889 --- /dev/null +++ b/packages/discolike/tests/test_auth.py @@ -0,0 +1,193 @@ +import asyncio +import threading +import time + +import httpx2 +import pytest + +from discolike import AuthenticationError +from discolike._auth import DiscolikeAuth +from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthCredential +from discolike._oauth import REFRESH_LEEWAY_SECONDS + +API = "https://api.test/v1" +TOKEN_ENDPOINT = "https://auth.test/oauth/2.1/token" +ONE_HOUR = 3600.0 + + +def make_oauth(*, expires_in: float = ONE_HOUR, access_token: str = "at-1") -> OAuthCredential: + return OAuthCredential( + access_token=access_token, + refresh_token="rt-1", + expires_at=time.time() + expires_in, + client_id="client-1", + token_endpoint=TOKEN_ENDPOINT, + ) + + +class Server: + """Mock API + token endpoint; counts refreshes and rejects any bearer it did not mint.""" + + def __init__(self, *, valid_tokens: set[str], refresh_ok: bool = True) -> None: + self.valid_tokens = valid_tokens + self.refresh_ok = refresh_ok + self.refreshes = 0 + self.bearers: list[str] = [] + self.token_calls: list[httpx2.Request] = [] + + def __call__(self, request: httpx2.Request) -> httpx2.Response: + if str(request.url) == TOKEN_ENDPOINT: + self.token_calls.append(request) + if not self.refresh_ok: + return httpx2.Response(400, json={"error": "invalid_grant", "error_description": "revoked"}) + self.refreshes += 1 + token = f"at-refreshed-{self.refreshes}" + self.valid_tokens.add(token) + return httpx2.Response( + 200, json={"access_token": token, "refresh_token": f"rt-{self.refreshes + 1}", "expires_in": 3600} + ) + bearer = request.headers.get("Authorization", "") + self.bearers.append(bearer) + if bearer.removeprefix("Bearer ") in self.valid_tokens: + return httpx2.Response(200, json={"ok": True}) + return httpx2.Response(401, json={"detail": "Invalid API Key or Session"}) + + +def sync_client(auth: DiscolikeAuth, handler) -> httpx2.Client: + return httpx2.Client(transport=httpx2.MockTransport(handler), base_url=API, auth=auth) + + +def async_client(auth: DiscolikeAuth, handler) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url=API, auth=auth) + + +def test_api_key_credential_sets_header() -> None: + seen: dict[str, str] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.update(request.headers) + return httpx2.Response(200) + + sync_client(DiscolikeAuth(ApiKeyCredential(api_key="dk-1")), handler).get("/usage") + assert seen["x-discolike-key"] == "dk-1" + assert "authorization" not in seen + + +def test_oauth_credential_sets_bearer_without_refresh() -> None: + server = Server(valid_tokens={"at-1"}) + response = sync_client(DiscolikeAuth(make_oauth()), server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 0 + assert server.bearers[0] == "Bearer at-1" + + +def test_proactive_refresh_when_near_expiry() -> None: + server = Server(valid_tokens={"at-1"}) + updates: list[OAuthCredential] = [] + auth = DiscolikeAuth(make_oauth(expires_in=REFRESH_LEEWAY_SECONDS / 2), on_update=updates.append) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + assert server.bearers[0] == "Bearer at-refreshed-1" + assert [update.access_token for update in updates] == ["at-refreshed-1"] + assert updates[0].refresh_token == "rt-2" + assert updates[0].client_id == "client-1" + assert updates[0].token_endpoint == TOKEN_ENDPOINT + assert auth.credential is updates[0] + + +def test_refresh_request_shape() -> None: + server = Server(valid_tokens=set()) + sync_client(DiscolikeAuth(make_oauth(expires_in=0)), server).get("/usage") + token_request = server.token_calls[0] + assert token_request.method == "POST" + assert token_request.headers["Content-Type"] == "application/x-www-form-urlencoded" + assert token_request.content.decode() == "grant_type=refresh_token&refresh_token=rt-1&client_id=client-1" + + +def test_401_triggers_refresh_and_single_replay() -> None: + server = Server(valid_tokens=set()) + updates: list[OAuthCredential] = [] + response = sync_client(DiscolikeAuth(make_oauth(), on_update=updates.append), server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + assert server.bearers == ["Bearer at-1", "Bearer at-refreshed-1"] + assert updates[0].access_token == "at-refreshed-1" + + +def test_second_401_after_replay_is_returned_not_retried() -> None: + server = Server(valid_tokens=set()) + + def reject_everything(request: httpx2.Request) -> httpx2.Response: + response = server(request) + if str(request.url) != TOKEN_ENDPOINT: + return httpx2.Response(401, json={"detail": "Invalid API Key or Session"}) + return response + + response = sync_client(DiscolikeAuth(make_oauth()), reject_everything).get("/usage") + assert response.status_code == 401 + assert server.refreshes == 1 + assert len(server.bearers) == 2 + + +def test_refresh_failure_raises_authentication_error() -> None: + server = Server(valid_tokens=set(), refresh_ok=False) + with pytest.raises(AuthenticationError, match="discolike auth login"): + sync_client(DiscolikeAuth(make_oauth(expires_in=0)), server).get("/usage") + + +def test_concurrent_requests_refresh_once() -> None: + server = Server(valid_tokens=set()) + auth = DiscolikeAuth(make_oauth(expires_in=0)) + client = sync_client(auth, server) + statuses: list[int] = [] + + def worker() -> None: + statuses.append(client.get("/usage").status_code) + + threads = [threading.Thread(target=worker) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert statuses == [200, 200, 200, 200] + assert server.refreshes == 1 + + +async def test_async_bearer_and_401_replay() -> None: + server = Server(valid_tokens=set()) + updates: list[OAuthCredential] = [] + async with async_client(DiscolikeAuth(make_oauth(), on_update=updates.append), server) as client: + response = await client.get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + assert server.bearers == ["Bearer at-1", "Bearer at-refreshed-1"] + assert updates[0].access_token == "at-refreshed-1" + + +async def test_async_proactive_refresh_and_concurrency() -> None: + server = Server(valid_tokens=set()) + async with async_client(DiscolikeAuth(make_oauth(expires_in=0)), server) as client: + responses = await asyncio.gather(*(client.get("/usage") for _ in range(4))) + assert [response.status_code for response in responses] == [200, 200, 200, 200] + assert server.refreshes == 1 + + +async def test_async_refresh_failure_raises_authentication_error() -> None: + server = Server(valid_tokens=set(), refresh_ok=False) + async with async_client(DiscolikeAuth(make_oauth(expires_in=0)), server) as client: + with pytest.raises(AuthenticationError, match="discolike auth login"): + await client.get("/usage") + + +async def test_async_api_key_header() -> None: + seen: dict[str, str] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.update(request.headers) + return httpx2.Response(200) + + async with async_client(DiscolikeAuth(ApiKeyCredential(api_key="dk-2")), handler) as client: + await client.get("/usage") + assert seen["x-discolike-key"] == "dk-2" diff --git a/packages/discolike/tests/test_client.py b/packages/discolike/tests/test_client.py index 9232cbe..09ae2b1 100644 --- a/packages/discolike/tests/test_client.py +++ b/packages/discolike/tests/test_client.py @@ -1,8 +1,16 @@ +import time + import httpx2 import pytest +from discolike import AsyncDiscolike from discolike import AuthenticationError from discolike import Discolike +from discolike import OAuthCredential +from discolike._auth import DiscolikeAuth +from discolike._config import config_path +from discolike._config import load_credential +from discolike._config import save_credential from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -92,3 +100,68 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with client.with_options(timeout=30.0) as view: await view.account.usage() await client.account.usage() + + +def test_client_accepts_injected_credential_and_does_not_persist_refresh(monkeypatch) -> None: + credential = OAuthCredential( + access_token="at", refresh_token="rt", expires_at=time.time() + 3600, client_id="c", token_endpoint="https://t" + ) + seen: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.headers["Authorization"]) + return httpx2.Response(200, json={"requests_mtd": 1}) + + http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + with Discolike(auth=credential, base_url="https://api.test/v1", http_client=http) as client: + client.account.usage() + auth = client._transport._client.auth + assert isinstance(auth, DiscolikeAuth) + assert auth.on_update is None + assert seen == ["Bearer at"] + assert not config_path().exists() + + +def test_client_from_config_oauth_persists_rotated_tokens() -> None: + save_credential( + OAuthCredential( + access_token="stale", refresh_token="rt-1", expires_at=0.0, client_id="c", token_endpoint="https://t/token" + ) + ) + + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == "https://t/token": + return httpx2.Response(200, json={"access_token": "fresh", "refresh_token": "rt-2", "expires_in": 3600}) + assert request.headers["Authorization"] == "Bearer fresh" + return httpx2.Response(200, json={"requests_mtd": 1}) + + http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + with Discolike(base_url="https://api.test/v1", http_client=http) as client: + assert client.account.usage().requests_mtd == 1 + stored = load_credential() + assert isinstance(stored, OAuthCredential) + assert (stored.access_token, stored.refresh_token) == ("fresh", "rt-2") + + +def test_with_options_view_shares_auth(make_client: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"balance": 1}) + + with make_client(handler) as client: + assert client.with_options(timeout=1.0)._transport._client.auth is client._transport._client.auth + + +async def test_async_client_accepts_injected_credential() -> None: + seen: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.headers["Authorization"]) + return httpx2.Response(200, json={"requests_mtd": 1}) + + credential = OAuthCredential( + access_token="at", refresh_token="rt", expires_at=time.time() + 3600, client_id="c", token_endpoint="https://t" + ) + http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + async with AsyncDiscolike(auth=credential, base_url="https://api.test/v1", http_client=http) as client: + await client.account.usage() + assert seen == ["Bearer at"] diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index 9aaee96..6d0d19d 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -5,8 +5,13 @@ from discolike import AuthenticationError from discolike._config import config_path from discolike._config import load_config +from discolike._config import load_credential from discolike._config import resolve_api_key +from discolike._config import resolve_credential from discolike._config import save_config +from discolike._config import save_credential +from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthCredential @pytest.fixture(autouse=True) @@ -63,3 +68,55 @@ def test_binary_garbage_config_returns_empty(isolated_config) -> None: config_path().parent.mkdir(parents=True, exist_ok=True) config_path().write_bytes(b"\xff\xfe\x00garbage") assert load_config() == {} + + +def _oauth_credential() -> OAuthCredential: + return OAuthCredential( + access_token="at", refresh_token="rt", expires_at=1.0, client_id="c", token_endpoint="https://t/token" + ) + + +def test_save_and_load_oauth_credential(isolated_config) -> None: + save_credential(_oauth_credential()) + stored = load_config() + assert stored["auth_method"] == "oauth" + assert stored["oauth"] == { + "access_token": "at", + "refresh_token": "rt", + "expires_at": 1.0, + "client_id": "c", + "token_endpoint": "https://t/token", + } + assert load_credential() == _oauth_credential() + + +def test_save_api_key_credential_keeps_legacy_shape(isolated_config) -> None: + save_credential(ApiKeyCredential(api_key="dk-1")) + assert load_config() == {"auth_method": "api_key", "api_key": "dk-1"} + assert resolve_api_key(None) == "dk-1" + + +def test_load_credential_without_auth_method_is_api_key(isolated_config) -> None: + save_config({"api_key": "legacy"}) + assert load_credential() == ApiKeyCredential(api_key="legacy") + assert resolve_credential() == ApiKeyCredential(api_key="legacy") + + +def test_load_credential_missing_returns_none(isolated_config) -> None: + assert load_credential() is None + + +def test_resolve_credential_precedence(isolated_config, monkeypatch) -> None: + save_credential(_oauth_credential()) + monkeypatch.setenv("DISCOLIKE_API_KEY", "from-env") + injected = ApiKeyCredential(api_key="injected") + assert resolve_credential(api_key="explicit", auth=injected) is injected + assert resolve_credential(api_key="explicit") == ApiKeyCredential(api_key="explicit") + assert resolve_credential() == ApiKeyCredential(api_key="from-env") + monkeypatch.delenv("DISCOLIKE_API_KEY") + assert resolve_credential() == _oauth_credential() + + +def test_resolve_credential_nothing_raises_with_guidance(isolated_config) -> None: + with pytest.raises(AuthenticationError, match="discolike auth login"): + resolve_credential() diff --git a/packages/discolike/tests/test_jobs.py b/packages/discolike/tests/test_jobs.py index c9d81ab..d71a463 100644 --- a/packages/discolike/tests/test_jobs.py +++ b/packages/discolike/tests/test_jobs.py @@ -9,6 +9,7 @@ from discolike._jobs import Job from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike_testkit import api_key_auth BASE = "https://api.test/v1" @@ -25,7 +26,7 @@ async def fake_sleep(seconds: float) -> None: def make_job(handler) -> Job: http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url=BASE) - transport = Transport("k", base_url=BASE, timeout=5.0, max_retries=0, http_client=http) + transport = Transport(api_key_auth("k"), base_url=BASE, timeout=5.0, max_retries=0, http_client=http) return Job(transport, task_family=FAMILY_DISCOGEN, task_id="t-1") @@ -123,6 +124,6 @@ async def test_async_job_wait() -> None: [{"status": "in_progress", "progress": 5}, {"status": "completed", "progress": 100, "results": []}] ) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url=BASE) - transport = AsyncTransport("k", base_url=BASE, timeout=5.0, max_retries=0, http_client=http) + transport = AsyncTransport(api_key_auth("k"), base_url=BASE, timeout=5.0, max_retries=0, http_client=http) final = await AsyncJob(transport, task_family=FAMILY_DISCOGEN, task_id="t-1").wait(timeout=60.0) assert final.status == "completed" diff --git a/packages/discolike/tests/test_oauth.py b/packages/discolike/tests/test_oauth.py new file mode 100644 index 0000000..3a81042 --- /dev/null +++ b/packages/discolike/tests/test_oauth.py @@ -0,0 +1,226 @@ +import base64 +import hashlib +import time +from urllib.parse import parse_qs +from urllib.parse import urlparse + +import httpx2 +import pytest + +from discolike import AuthenticationError +from discolike._credentials import OAuthCredential +from discolike._oauth import CLIENT_NAME +from discolike._oauth import AuthServerMetadata +from discolike._oauth import build_authorization_url +from discolike._oauth import discover +from discolike._oauth import exchange_code +from discolike._oauth import pkce_pair +from discolike._oauth import refresh +from discolike._oauth import register_client + +BASE_URL = "https://api.test/v1" +METADATA = AuthServerMetadata( + authorization_endpoint="https://auth.test/oauth/2.1/authorize", + token_endpoint="https://auth.test/oauth/2.1/token", + registration_endpoint="https://auth.test/oauth/2.1/register", +) + + +def client_for(handler) -> httpx2.Client: + return httpx2.Client(transport=httpx2.MockTransport(handler)) + + +def form(request: httpx2.Request) -> dict[str, str]: + return {key: values[0] for key, values in parse_qs(request.content.decode()).items()} + + +def test_pkce_pair_is_s256() -> None: + verifier, challenge = pkce_pair() + expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + assert challenge == expected + assert "=" not in verifier + assert 43 <= len(verifier) <= 128 + + +def test_discover_reads_well_known_under_base_url() -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response( + 200, + json={ + "issuer": "https://auth.test", + "authorization_endpoint": METADATA.authorization_endpoint, + "token_endpoint": METADATA.token_endpoint, + "registration_endpoint": METADATA.registration_endpoint, + }, + ) + + assert discover(BASE_URL + "/", client=client_for(handler)) == METADATA + assert str(seen[0].url) == "https://api.test/v1/.well-known/oauth-authorization-server" + + +def test_discover_missing_field_raises() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"authorization_endpoint": "x", "token_endpoint": "y"}) + + with pytest.raises(AuthenticationError, match="registration_endpoint"): + discover(BASE_URL, client=client_for(handler)) + + +def test_register_client_sends_public_client_metadata() -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(201, json={"client_id": "client-abc", "client_secret": ""}) + + client_id = register_client(METADATA, redirect_uris=["http://127.0.0.1:9999/callback"], client=client_for(handler)) + assert client_id == "client-abc" + request = seen[0] + assert request.method == "POST" + assert str(request.url) == METADATA.registration_endpoint + body = httpx2.Response(200, content=request.content).json() + assert body["client_name"] == CLIENT_NAME + assert body["redirect_uris"] == ["http://127.0.0.1:9999/callback"] + assert body["grant_types"] == ["authorization_code", "refresh_token"] + assert body["response_types"] == ["code"] + assert body["token_endpoint_auth_method"] == "none" + + +def test_build_authorization_url_carries_pkce_state_and_resource() -> None: + url = build_authorization_url( + METADATA, + client_id="client-abc", + redirect_uri="http://127.0.0.1:9999/callback", + code_challenge="chal", + state="st", + resource=BASE_URL, + ) + parsed = urlparse(url) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == METADATA.authorization_endpoint + query = {key: values[0] for key, values in parse_qs(parsed.query).items()} + assert query == { + "response_type": "code", + "client_id": "client-abc", + "redirect_uri": "http://127.0.0.1:9999/callback", + "code_challenge": "chal", + "code_challenge_method": "S256", + "state": "st", + "resource": BASE_URL, + "scope": "offline_access", + } + + +def test_exchange_code_posts_form_and_builds_credential() -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response( + 200, json={"access_token": "at", "refresh_token": "rt", "expires_in": 3600, "token_type": "Bearer"} + ) + + before = time.time() + credential = exchange_code( + METADATA, + client_id="client-abc", + code="the-code", + code_verifier="ver", + redirect_uri="http://127.0.0.1:9999/callback", + resource=BASE_URL, + client=client_for(handler), + ) + request = seen[0] + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + assert form(request) == { + "grant_type": "authorization_code", + "client_id": "client-abc", + "code": "the-code", + "code_verifier": "ver", + "redirect_uri": "http://127.0.0.1:9999/callback", + "resource": BASE_URL, + } + assert credential.access_token == "at" + assert credential.refresh_token == "rt" + assert credential.client_id == "client-abc" + assert credential.token_endpoint == METADATA.token_endpoint + assert before + 3600 <= credential.expires_at <= time.time() + 3600 + + +def test_exchange_code_without_refresh_token_raises() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"access_token": "at", "expires_in": 3600}) + + with pytest.raises(AuthenticationError, match="refresh_token"): + exchange_code( + METADATA, + client_id="c", + code="x", + code_verifier="v", + redirect_uri="http://127.0.0.1:1/callback", + resource=BASE_URL, + client=client_for(handler), + ) + + +def test_oauth_error_body_maps_to_authentication_error() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(400, json={"error": "invalid_grant", "error_description": "code expired"}) + + with pytest.raises(AuthenticationError, match="invalid_grant: code expired") as exc_info: + exchange_code( + METADATA, + client_id="c", + code="x", + code_verifier="v", + redirect_uri="http://127.0.0.1:1/callback", + resource=BASE_URL, + client=client_for(handler), + ) + assert exc_info.value.status_code == 400 + + +def test_non_json_error_maps_to_authentication_error() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(502, text="bad gateway") + + with pytest.raises(AuthenticationError, match="502"): + discover(BASE_URL, client=client_for(handler)) + + +def test_refresh_rotates_tokens_and_keeps_old_refresh_token_when_absent() -> None: + credential = OAuthCredential( + access_token="old", + refresh_token="rt-old", + expires_at=0.0, + client_id="c", + token_endpoint=METADATA.token_endpoint, + ) + seen: list[httpx2.Request] = [] + + def rotating(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json={"access_token": "new", "refresh_token": "rt-new", "expires_in": 60}) + + rotated = refresh(credential, client=client_for(rotating)) + assert form(seen[0]) == {"grant_type": "refresh_token", "refresh_token": "rt-old", "client_id": "c"} + assert (rotated.access_token, rotated.refresh_token) == ("new", "rt-new") + assert rotated.client_id == "c" + assert rotated.token_endpoint == METADATA.token_endpoint + + def not_rotating(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"access_token": "newer", "expires_in": 60}) + + kept = refresh(rotated, client=client_for(not_rotating)) + assert (kept.access_token, kept.refresh_token) == ("newer", "rt-new") + + +def test_credential_config_roundtrip_and_expiry() -> None: + credential = OAuthCredential( + access_token="a", refresh_token="r", expires_at=1000.0, client_id="c", token_endpoint="https://t" + ) + assert OAuthCredential.from_config(credential.to_config()) == credential + assert credential.expires_within(60, now=950.0) + assert not credential.expires_within(60, now=900.0) diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index cd97cc1..5700221 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.3.0" + assert discolike.__version__ == "0.4.0" diff --git a/packages/discolike/tests/test_transport.py b/packages/discolike/tests/test_transport.py index 69149cc..1d83e01 100644 --- a/packages/discolike/tests/test_transport.py +++ b/packages/discolike/tests/test_transport.py @@ -7,6 +7,7 @@ from discolike._transport import AsyncTransport from discolike._transport import Transport from discolike._transport import drop_none +from discolike_testkit import api_key_auth @pytest.fixture(autouse=True) @@ -23,7 +24,9 @@ async def fake_async_sleep(seconds: float) -> None: def make_transport(handler) -> Transport: http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - return Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + return Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) def test_drop_none() -> None: @@ -102,7 +105,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: from discolike import ServerError http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + transport = Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) with pytest.raises(ServerError): transport.request("GET", "/usage") assert len(calls) == 1 @@ -117,7 +122,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(502) if len(calls) < 2 else httpx2.Response(200, json={"ok": True}) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = AsyncTransport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) response = await transport.request("GET", "/usage") assert response.json() == {"ok": True} await transport.aclose() @@ -201,7 +208,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: from discolike import ServerError http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = AsyncTransport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) with pytest.raises(ServerError): await transport.request("POST", "/discogen/process") assert len(calls) == 1 @@ -218,7 +227,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"ok": True}) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = AsyncTransport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) response = await transport.request("POST", "/discogen/process") assert response.json() == {"ok": True} assert len(calls) == 2 @@ -233,14 +244,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"ok": True}) http = httpx2.Client(transport=httpx2.MockTransport(handler)) - transport = Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + transport = Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) transport.request("GET", "/usage") assert seen["url"] == "https://api.test/v1/usage" def test_byo_http_client_with_base_url_is_left_alone() -> None: http = httpx2.Client(base_url="https://custom.example/v2") - Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + Transport(api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) assert str(http.base_url) == "https://custom.example/v2/" @@ -252,7 +265,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"ok": True}) http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1", timeout=5.0) - transport = Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + transport = Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) transport.with_timeout(120.0).request("GET", "/usage") transport.request("GET", "/usage") diff --git a/pyproject.toml b/pyproject.toml index a534ea7..f5aa784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,4 +70,4 @@ ban-relative-imports = "all" force-single-line = true [tool.ruff.lint.per-file-ignores] -"packages/*/tests/*" = ["ANN"] +"packages/*/tests/*" = ["ANN", "S105", "S106", "S107"] # fake tokens in fixtures diff --git a/uv.lock b/uv.lock index 2de2feb..8b85bde 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.3.0" +version = "0.4.0" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" }, From 5972d50ce0bc71d8fcca4ce2d19c5b8c9a57fba8 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 09:33:41 -0700 Subject: [PATCH 31/44] feat(cli): auth login via OAuth loopback flow `discolike auth login` now defaults to a browser login: discover the authorization server through the API, register a public client via DCR, run PKCE authorization-code against a loopback redirect, and save the resulting OAuth credential. The redirect URI is registered with the actual bound port (random by default, --port to pin for SSH forwarding) so it works whether or not the server relaxes loopback port matching. The API-key path stays reachable through --api-key or --method api_key, which preserves the prompt and the existing stderr JSON. auth status adds a method field and, for OAuth, expiry information. --- CHANGELOG.md | 5 + packages/discolike-cli/README.md | 2 +- packages/discolike-cli/pyproject.toml | 4 +- .../src/discolike_cli/_loopback.py | 63 ++++++ .../discolike-cli/src/discolike_cli/auth.py | 160 +++++++++++-- packages/discolike-cli/tests/test_auth.py | 4 +- .../discolike-cli/tests/test_auth_oauth.py | 212 ++++++++++++++++++ 7 files changed, 423 insertions(+), 27 deletions(-) create mode 100644 packages/discolike-cli/src/discolike_cli/_loopback.py create mode 100644 packages/discolike-cli/tests/test_auth_oauth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa242e..f8213c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape. +- SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced. +- CLI: `discolike auth login` now logs in through the browser by default (PKCE authorization-code flow against the platform's OAuth server, loopback redirect on `127.0.0.1`). `--no-browser` prints the URL only, `--port` pins the loopback port for SSH forwarding, and `--method api_key` (or `--api-key KEY`) keeps the API-key flow, including the prompt. Login-flow failures (timeout, denied consent, state mismatch) exit 1 with `{"error": "LoginError", ...}` on stderr. +- CLI: `discolike auth status` adds `method` (`api_key` / `oauth`); for OAuth it reports `expires_at` and `expired` instead of a masked key. + - SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened. ## 0.3.0 (2026-08-27) diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index 2f4f463..bab4b42 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -22,7 +22,7 @@ Requires Python 3.10+. Installing this package gives you the `discolike` command discolike auth login ``` -Prompts for an API key (or pass `--api-key`) and verifies it against your account. Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead. +Opens your browser to log in (add `--no-browser` to print the URL instead, `--port` to pin the loopback port when forwarding over SSH). To use an API key instead, pass `--api-key KEY` or `--method api_key` to be prompted; create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead. ## Quickstart diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index acb5f6f..00418ff 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.3.0" +version = "0.4.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.3.0", + "discolike==0.4.0", "typer>=0.12", "rich>=13.0", ] diff --git a/packages/discolike-cli/src/discolike_cli/_loopback.py b/packages/discolike-cli/src/discolike_cli/_loopback.py new file mode 100644 index 0000000..dab0ecc --- /dev/null +++ b/packages/discolike-cli/src/discolike_cli/_loopback.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler +from http.server import HTTPServer +from urllib.parse import parse_qs +from urllib.parse import urlparse + +LOOPBACK_HOST = "127.0.0.1" +CALLBACK_PATH = "/callback" +CALLBACK_HTML = "DiscoLike

Login complete. You can close this window.

" + + +class _LoopbackServer(HTTPServer): + def __init__(self, *, host: str, port: int) -> None: + super().__init__((host, port), _CallbackHandler) + self.query: dict[str, str] = {} + self.received = threading.Event() + + +class _CallbackHandler(BaseHTTPRequestHandler): + server: _LoopbackServer + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path != CALLBACK_PATH: + self.send_error(HTTPStatus.NOT_FOUND) + return + self.server.query = {key: values[0] for key, values in parse_qs(parsed.query).items()} + body = CALLBACK_HTML.encode() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + self.server.received.set() + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 -- BaseHTTPRequestHandler signature + _ = (format, args) + + +class CallbackServer: + """Loopback redirect target for the authorization-code flow; serves on a daemon thread.""" + + def __init__(self, *, port: int) -> None: + self._server = _LoopbackServer(host=LOOPBACK_HOST, port=port) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + @property + def redirect_uri(self) -> str: + return f"http://{LOOPBACK_HOST}:{self._server.server_port}{CALLBACK_PATH}" + + def wait(self, *, timeout: float) -> dict[str, str] | None: + return self._server.query if self._server.received.wait(timeout) else None + + def __enter__(self) -> CallbackServer: + self._thread.start() + return self + + def __exit__(self, *exc_info: object) -> None: + self._server.shutdown() + self._server.server_close() diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index e1a0f6a..0dd48c0 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -1,76 +1,192 @@ from __future__ import annotations import json +import secrets import sys +import webbrowser +from datetime import datetime +from datetime import timezone from typing import Any +from typing import NoReturn +import httpx2 import typer +from discolike._config import AUTH_METHOD_API_KEY +from discolike._config import AUTH_METHOD_OAUTH +from discolike._config import DEFAULT_BASE_URL from discolike._config import KEYS_URL +from discolike._config import NO_CREDENTIAL_MESSAGE from discolike._config import delete_config -from discolike._config import load_config -from discolike._config import resolve_api_key +from discolike._config import load_credential from discolike._config import save_config +from discolike._config import save_credential +from discolike._credentials import OAuthCredential +from discolike._exceptions import AuthenticationError +from discolike._oauth import build_authorization_url +from discolike._oauth import discover +from discolike._oauth import exchange_code +from discolike._oauth import pkce_pair +from discolike._oauth import register_client +from discolike_cli._loopback import CallbackServer from discolike_cli._output import emit from discolike_cli._output import handle_errors -app = typer.Typer(help="Manage API credentials: log in, check key status, log out.") +app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.") MASKED_VISIBLE_CHARS = 4 +LOGIN_TIMEOUT_SECONDS = 180.0 +OAUTH_HTTP_TIMEOUT_SECONDS = 30.0 +STATE_BYTES = 16 +RANDOM_PORT = 0 SOURCE_OPTION = "option" SOURCE_ENV = "env" SOURCE_CONFIG = "config" +LOGIN_METHODS = (AUTH_METHOD_OAUTH, AUTH_METHOD_API_KEY) + def _mask(key: str) -> str: return "…" + key[-MASKED_VISIBLE_CHARS:] +def _iso(epoch_seconds: float) -> str: + return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat() + + def _global_key_source(ctx: typer.Context) -> str: # typer vendors click without re-exporting ParameterSource, so match on the enum member name. source = ctx.find_root().get_parameter_source("api_key") return SOURCE_ENV if source is not None and source.name == "ENVIRONMENT" else SOURCE_OPTION -def _verify(ctx: typer.Context, *, api_key: str) -> None: +def _verify(ctx: typer.Context, **kwargs: Any) -> None: # noqa: ANN401 -- forwarded verbatim to build_client from discolike_cli.main import build_client - kwargs: dict[str, Any] = {"api_key": api_key} base_url = ctx.obj.get("base_url") if base_url is not None: kwargs["base_url"] = base_url build_client(**kwargs).account.usage() +def _abort_login(message: str) -> NoReturn: + print(json.dumps({"error": "LoginError", "message": message}), file=sys.stderr) + raise typer.Exit(code=1) + + +def _oauth_login(ctx: typer.Context, *, open_browser: bool, port: int) -> OAuthCredential: + base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/") + with httpx2.Client(timeout=OAUTH_HTTP_TIMEOUT_SECONDS) as http, CallbackServer(port=port) as server: + metadata = discover(base_url, client=http) + redirect_uri = server.redirect_uri + client_id = register_client(metadata, redirect_uris=[redirect_uri], client=http) + verifier, challenge = pkce_pair() + state = secrets.token_urlsafe(STATE_BYTES) + url = build_authorization_url( + metadata, + client_id=client_id, + redirect_uri=redirect_uri, + code_challenge=challenge, + state=state, + resource=base_url, + ) + print(f"Open this URL in your browser to log in:\n{url}", file=sys.stderr) + if open_browser and not webbrowser.open(url): + print("Could not open a browser; open the URL above manually.", file=sys.stderr) + callback = server.wait(timeout=LOGIN_TIMEOUT_SECONDS) + if callback is None: + _abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login") + if "error" in callback: + _abort_login(f"Authorization failed: {callback.get('error_description') or callback['error']}") + if callback.get("state") != state or "code" not in callback: + _abort_login("Invalid OAuth callback (state mismatch or missing code)") + return exchange_code( + metadata, + client_id=client_id, + code=callback["code"], + code_verifier=verifier, + redirect_uri=redirect_uri, + resource=base_url, + client=http, + ) + + +def _api_key_login(ctx: typer.Context, *, api_key: str | None) -> None: + # An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may. + passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None + key = api_key or passed_globally or typer.prompt("API key", hide_input=True) + _verify(ctx, api_key=key) + save_config({"auth_method": AUTH_METHOD_API_KEY, "api_key": key}) + print(json.dumps({"logged_in": True, "source": AUTH_METHOD_API_KEY}), file=sys.stderr) + + @app.command() @handle_errors def login( ctx: typer.Context, - api_key: str | None = typer.Option(None, help=f"API key. Create one at {KEYS_URL}. Prompted for if omitted."), + api_key: str | None = typer.Option( + None, help=f"Log in with an API key instead of the browser. Create one at {KEYS_URL}." + ), + method: str = typer.Option( + AUTH_METHOD_OAUTH, + "--method", + help="oauth (browser login, default) or api_key (prompts for a key unless --api-key is given).", + ), + no_browser: bool = typer.Option(False, "--no-browser", help="Print the login URL instead of opening a browser."), + port: int = typer.Option( + RANDOM_PORT, "--port", help="Fixed loopback port for the browser redirect (default: random; use with SSH)." + ), ) -> None: - """Verify an API key and save it to the local config file.""" - # An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may. - passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None - key = api_key or passed_globally or typer.prompt("API key", hide_input=True) - _verify(ctx, api_key=key) - save_config({"auth_method": "api_key", "api_key": key}) - print(json.dumps({"logged_in": True, "source": "api_key"}), file=sys.stderr) + """Log in via the browser (OAuth) or with an API key, verify, and save to the local config file.""" + if method not in LOGIN_METHODS: + raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method") + global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION + if api_key or global_key_passed or method == AUTH_METHOD_API_KEY: + _api_key_login(ctx, api_key=api_key) + return + credential = _oauth_login(ctx, open_browser=not no_browser, port=port) + _verify(ctx, auth=credential) + save_credential(credential) + print( + json.dumps({"logged_in": True, "method": AUTH_METHOD_OAUTH, "expires_at": _iso(credential.expires_at)}), + file=sys.stderr, + ) @app.command() @handle_errors def status(ctx: typer.Context) -> None: - """Show which API key is in use (option, env, or config) and verify it against the API.""" + """Show which credential is in use (option, env, or config) and verify it against the API.""" key = ctx.obj.get("api_key") - source = _global_key_source(ctx) - if not key: - key = load_config().get("api_key") - source = SOURCE_CONFIG - if not key: - resolve_api_key(None) - _verify(ctx, api_key=str(key)) - emit({"source": source, "api_key": _mask(str(key)), "valid": True}) + if key: + _verify(ctx, api_key=str(key)) + emit( + { + "source": _global_key_source(ctx), + "method": AUTH_METHOD_API_KEY, + "api_key": _mask(str(key)), + "valid": True, + } + ) + return + credential = load_credential() + if credential is None: + raise AuthenticationError(NO_CREDENTIAL_MESSAGE) + if isinstance(credential, OAuthCredential): + _verify(ctx) + emit( + { + "source": SOURCE_CONFIG, + "method": AUTH_METHOD_OAUTH, + "expires_at": _iso(credential.expires_at), + "expired": credential.expires_within(0), + "valid": True, + } + ) + return + _verify(ctx, api_key=credential.api_key) + emit({"source": SOURCE_CONFIG, "method": AUTH_METHOD_API_KEY, "api_key": _mask(credential.api_key), "valid": True}) @app.command() diff --git a/packages/discolike-cli/tests/test_auth.py b/packages/discolike-cli/tests/test_auth.py index b2f5f72..f7b3de9 100644 --- a/packages/discolike-cli/tests/test_auth.py +++ b/packages/discolike-cli/tests/test_auth.py @@ -39,7 +39,7 @@ def test_login_with_api_key_option_verifies_and_saves(install_build_client: Call def test_login_prompts_for_key_when_not_given(install_build_client: Callable[[Handler], None]) -> None: install_build_client(_usage_ok) - result = runner.invoke(app, ["auth", "login"], input="dk-2\n") + result = runner.invoke(app, ["auth", "login", "--method", "api_key"], input="dk-2\n") assert result.exit_code == 0, result.output assert json.loads(config_path().read_text())["api_key"] == "dk-2" @@ -196,7 +196,7 @@ def test_login_ignores_ambient_env_key_and_still_prompts( ) -> None: install_build_client(_usage_ok) monkeypatch.setenv(ENV_API_KEY, "dk-from-env") - result = runner.invoke(app, ["auth", "login"], input="dk-typed\n") + result = runner.invoke(app, ["auth", "login", "--method", "api_key"], input="dk-typed\n") assert result.exit_code == 0, result.output assert json.loads(config_path().read_text())["api_key"] == "dk-typed" diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py new file mode 100644 index 0000000..806a6ff --- /dev/null +++ b/packages/discolike-cli/tests/test_auth_oauth.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import dataclasses +import json +import threading +import time +from collections.abc import Callable +from typing import Any +from urllib.parse import parse_qs +from urllib.parse import urlparse +from urllib.request import urlopen + +import httpx2 +import pytest +from typer.testing import CliRunner + +import discolike_cli.auth as auth_module +from discolike._config import DEFAULT_BASE_URL +from discolike._config import config_path +from discolike._config import save_credential +from discolike._credentials import OAuthCredential +from discolike._oauth import AuthServerMetadata +from discolike_cli.main import app +from discolike_testkit import Handler + +runner = CliRunner() + +METADATA = AuthServerMetadata( + authorization_endpoint="https://auth.test/oauth/2.1/authorize", + token_endpoint="https://auth.test/oauth/2.1/token", + registration_endpoint="https://auth.test/oauth/2.1/register", +) +CREDENTIAL = OAuthCredential( + access_token="at-1", + refresh_token="rt-1", + expires_at=1_800_000_000.0, + client_id="client-1", + token_endpoint=METADATA.token_endpoint, +) + + +def _usage_ok(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"requests_mtd": 1}) + + +def _usage_unauthorized(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(401, json={"detail": "Invalid API Key or Session"}) + + +class FakeProvider: + """Stands in for the authorization server and the user's browser.""" + + def __init__(self) -> None: + self.real_build_authorization_url = auth_module.build_authorization_url + self.discover_calls: list[str] = [] + self.register_calls: list[list[str]] = [] + self.exchange_calls: list[dict[str, Any]] = [] + self.opened_urls: list[str] = [] + self.callback_query: Callable[[dict[str, str]], str] = lambda query: f"code=the-code&state={query['state']}" + + def discover(self, base_url: str, *, client: httpx2.Client) -> AuthServerMetadata: + self.discover_calls.append(base_url) + return METADATA + + def register_client(self, metadata: AuthServerMetadata, *, redirect_uris: list[str], client: httpx2.Client) -> str: + self.register_calls.append(redirect_uris) + return "client-1" + + def exchange_code(self, metadata: AuthServerMetadata, *, client: httpx2.Client, **kwargs: Any) -> OAuthCredential: + self.exchange_calls.append(kwargs) + return CREDENTIAL + + def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str: + url = self.real_build_authorization_url(metadata, **kwargs) + query = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()} + callback = f"{query['redirect_uri']}?{self.callback_query(query)}" + threading.Thread(target=lambda: urlopen(callback).read(), daemon=True).start() # noqa: S310 -- loopback test server + return url + + def open(self, url: str) -> bool: + self.opened_urls.append(url) + return True + + +@pytest.fixture +def provider(monkeypatch: pytest.MonkeyPatch) -> FakeProvider: + fake = FakeProvider() + monkeypatch.setattr(auth_module, "discover", fake.discover) + monkeypatch.setattr(auth_module, "register_client", fake.register_client) + monkeypatch.setattr(auth_module, "exchange_code", fake.exchange_code) + monkeypatch.setattr(auth_module, "build_authorization_url", fake.build_authorization_url) + monkeypatch.setattr(auth_module.webbrowser, "open", fake.open) + return fake + + +def test_login_default_runs_oauth_loopback_flow( + provider: FakeProvider, + install_build_client: Callable[[Handler], None], + build_client_calls: list[dict[str, Any]], +) -> None: + install_build_client(_usage_ok) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert provider.discover_calls == [DEFAULT_BASE_URL] + redirect_uri = provider.register_calls[0][0] + assert redirect_uri.startswith("http://127.0.0.1:") + assert redirect_uri.endswith("/callback") + exchange = provider.exchange_calls[0] + assert exchange["code"] == "the-code" + assert exchange["client_id"] == "client-1" + assert exchange["redirect_uri"] == redirect_uri + assert exchange["resource"] == DEFAULT_BASE_URL + assert len(provider.opened_urls) == 1 + assert build_client_calls == [{"auth": CREDENTIAL}] + assert json.loads(config_path().read_text()) == {"auth_method": "oauth", "oauth": CREDENTIAL.to_config()} + payload = json.loads(result.stderr.splitlines()[-1]) + assert payload == {"logged_in": True, "method": "oauth", "expires_at": "2027-01-15T08:00:00+00:00"} + assert provider.opened_urls[0] in result.stderr + + +def test_login_no_browser_and_fixed_port( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + result = runner.invoke( + app, ["--base-url", "https://api.dev.test/v1/", "auth", "login", "--no-browser", "--port", "18484"] + ) + assert result.exit_code == 0, result.output + assert provider.opened_urls == [] + assert provider.register_calls == [["http://127.0.0.1:18484/callback"]] + assert provider.discover_calls == ["https://api.dev.test/v1"] + assert provider.exchange_calls[0]["resource"] == "https://api.dev.test/v1" + + +def test_login_state_mismatch_exits_1_and_saves_nothing( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + provider.callback_query = lambda query: "code=the-code&state=forged" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert not config_path().exists() + assert provider.exchange_calls == [] + assert json.loads(result.stderr.splitlines()[-1])["error"] == "LoginError" + + +def test_login_user_denied_exits_1(provider: FakeProvider, install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + provider.callback_query = lambda query: f"error=access_denied&error_description=nope&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert "nope" in json.loads(result.stderr.splitlines()[-1])["message"] + + +def test_login_timeout_exits_1( + provider: FakeProvider, install_build_client: Callable[[Handler], None], monkeypatch: pytest.MonkeyPatch +) -> None: + install_build_client(_usage_ok) + monkeypatch.setattr(auth_module, "LOGIN_TIMEOUT_SECONDS", 0.2) + monkeypatch.setattr(auth_module, "build_authorization_url", lambda metadata, **kwargs: "https://auth.test/never") + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"] + assert not config_path().exists() + + +def test_login_oauth_verify_failure_exits_3_and_saves_nothing( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_unauthorized) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 3 + assert not config_path().exists() + assert json.loads(result.stderr.splitlines()[-1])["error"] == "AuthenticationError" + + +def test_login_rejects_unknown_method(provider: FakeProvider) -> None: + result = runner.invoke(app, ["auth", "login", "--method", "magic"]) + assert result.exit_code == 2 + assert provider.discover_calls == [] + + +def test_status_reports_oauth_credential( + install_build_client: Callable[[Handler], None], build_client_calls: list[dict[str, Any]] +) -> None: + install_build_client(_usage_ok) + save_credential(CREDENTIAL) + result = runner.invoke(app, ["auth", "status"]) + assert result.exit_code == 0, result.output + assert build_client_calls == [{}] + assert json.loads(result.stdout) == { + "source": "config", + "method": "oauth", + "expires_at": "2027-01-15T08:00:00+00:00", + "expired": False, + "valid": True, + } + + +def test_status_flags_expired_oauth_credential(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + save_credential(dataclasses.replace(CREDENTIAL, expires_at=time.time() - 1)) + result = runner.invoke(app, ["auth", "status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["expired"] is True + + +def test_status_reports_api_key_method(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + result = runner.invoke(app, ["--api-key", "dk-abcdefgh1234", "auth", "status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["method"] == "api_key" From e59bc5dc8637c318841e25f8c8e61b3996180f0d Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 09:35:05 -0700 Subject: [PATCH 32/44] test(sdk): rename test_auth.py to avoid basename clash with CLI tests --- packages/discolike/tests/{test_auth.py => test_auth_flow.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/discolike/tests/{test_auth.py => test_auth_flow.py} (100%) diff --git a/packages/discolike/tests/test_auth.py b/packages/discolike/tests/test_auth_flow.py similarity index 100% rename from packages/discolike/tests/test_auth.py rename to packages/discolike/tests/test_auth_flow.py From ce60ddd470dcd00ba92f9deba7246283d7f630f3 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 09:40:51 -0700 Subject: [PATCH 33/44] fix(sdk): avoid cross-process refresh token race Two processes holding the same refresh token near expiry would both refresh; the second fails at the authorization server (rotation) and the last writer could overwrite the newer token pair. save_config now writes to a temp file in the config dir and renames it into place, so a reader never sees a partial file. DiscolikeAuth, when the credential came from the config file, re-reads the file under the refresh lock and adopts a fresher credential written by another process instead of refreshing with a refresh token that is already spent. --- packages/discolike/src/discolike/_auth.py | 55 +++++++++++++++++---- packages/discolike/src/discolike/_client.py | 7 ++- packages/discolike/src/discolike/_config.py | 6 ++- packages/discolike/tests/test_auth_flow.py | 50 +++++++++++++++++++ packages/discolike/tests/test_client.py | 28 +++++++++++ packages/discolike/tests/test_config.py | 8 +++ 6 files changed, 141 insertions(+), 13 deletions(-) diff --git a/packages/discolike/src/discolike/_auth.py b/packages/discolike/src/discolike/_auth.py index 9578a60..e85ba50 100644 --- a/packages/discolike/src/discolike/_auth.py +++ b/packages/discolike/src/discolike/_auth.py @@ -34,9 +34,16 @@ class DiscolikeAuth(httpx2.Auth): requires_response_body = False - def __init__(self, credential: Credential, *, on_update: Callable[[OAuthCredential], None] | None = None) -> None: + def __init__( + self, + credential: Credential, + *, + on_update: Callable[[OAuthCredential], None] | None = None, + reload: Callable[[], Credential | None] | None = None, + ) -> None: self._credential = credential self.on_update = on_update + self.reload = reload self._lock = threading.Lock() self._async_lock = asyncio.Lock() @@ -47,6 +54,20 @@ def credential(self) -> Credential: def _latest(self, credential: OAuthCredential) -> OAuthCredential: return cast(OAuthCredential, self._credential) if self._credential is not credential else credential + def _adopt_stored(self, credential: OAuthCredential) -> OAuthCredential | None: + """Another process may have rotated the tokens already; a refresh with our old refresh token would fail.""" + if self.reload is None: + return None + stored = self.reload() + if ( + not isinstance(stored, OAuthCredential) + or stored.access_token == credential.access_token + or stored.expires_within(REFRESH_LEEWAY_SECONDS) + ): + return None + self._credential = stored + return stored + def _store(self, response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential: try: rotated = parse_refresh_response(response, credential=credential) @@ -68,7 +89,11 @@ def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, h with self._lock: credential = self._latest(credential) if credential.expires_within(REFRESH_LEEWAY_SECONDS): - credential = yield from self._sync_refresh(credential) + adopted = self._adopt_stored(credential) + if adopted is not None: + credential = adopted + else: + credential = yield from self._sync_refresh(credential) _set_bearer(request, credential) response = yield request if response.status_code != UNAUTHORIZED: @@ -76,7 +101,11 @@ def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, h with self._lock: latest = self._latest(credential) if latest is credential: - latest = yield from self._sync_refresh(credential) + adopted = self._adopt_stored(credential) + if adopted is not None: + latest = adopted + else: + latest = yield from self._sync_refresh(credential) _set_bearer(request, latest) yield request @@ -94,9 +123,13 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx async with self._async_lock: credential = self._latest(credential) if credential.expires_within(REFRESH_LEEWAY_SECONDS): - response = yield refresh_request(credential) - await response.aread() - credential = self._store(response, credential=credential) + adopted = self._adopt_stored(credential) + if adopted is not None: + credential = adopted + else: + response = yield refresh_request(credential) + await response.aread() + credential = self._store(response, credential=credential) _set_bearer(request, credential) response = yield request if response.status_code != UNAUTHORIZED: @@ -104,8 +137,12 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx async with self._async_lock: latest = self._latest(credential) if latest is credential: - response = yield refresh_request(credential) - await response.aread() - latest = self._store(response, credential=credential) + adopted = self._adopt_stored(credential) + if adopted is not None: + latest = adopted + else: + response = yield refresh_request(credential) + await response.aread() + latest = self._store(response, credential=credential) _set_bearer(request, latest) yield request diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index 1a4b660..fc4998d 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -4,6 +4,7 @@ from discolike._auth import DiscolikeAuth from discolike._config import DEFAULT_BASE_URL +from discolike._config import load_credential from discolike._config import resolve_credential from discolike._config import save_credential from discolike._credentials import Credential @@ -51,9 +52,11 @@ def _build_auth(*, api_key: str | None, auth: Credential | None) -> DiscolikeAuth: - # Rotated refresh tokens are written back only when the credential came from the config file. + # The config file is read back and written only when the credential came from it. credential = resolve_credential(api_key=api_key, auth=auth) - return DiscolikeAuth(credential, on_update=save_credential if auth is None else None) + if auth is not None: + return DiscolikeAuth(credential) + return DiscolikeAuth(credential, on_update=save_credential, reload=load_credential) class Discolike: diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index cca3c90..6544978 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -2,6 +2,7 @@ import json import os +import tempfile from pathlib import Path from typing import Any @@ -41,10 +42,11 @@ def load_config() -> dict[str, Any]: def save_config(config: dict[str, Any]) -> None: path = config_path() path.parent.mkdir(parents=True, exist_ok=True) - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + fd, temp_path = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") with os.fdopen(fd, "w") as handle: handle.write(json.dumps(config, indent=2) + "\n") - path.chmod(0o600) + os.chmod(temp_path, 0o600) + os.replace(temp_path, path) def delete_config() -> None: diff --git a/packages/discolike/tests/test_auth_flow.py b/packages/discolike/tests/test_auth_flow.py index 9f9f889..a4eb24b 100644 --- a/packages/discolike/tests/test_auth_flow.py +++ b/packages/discolike/tests/test_auth_flow.py @@ -191,3 +191,53 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with async_client(DiscolikeAuth(ApiKeyCredential(api_key="dk-2")), handler) as client: await client.get("/usage") assert seen["x-discolike-key"] == "dk-2" + + +def test_reload_adopts_credential_rotated_by_another_process() -> None: + server = Server(valid_tokens={"at-other"}) + updates: list[OAuthCredential] = [] + rotated_elsewhere = make_oauth(access_token="at-other") + auth = DiscolikeAuth(make_oauth(expires_in=0), on_update=updates.append, reload=lambda: rotated_elsewhere) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.token_calls == [] + assert server.bearers == ["Bearer at-other"] + assert auth.credential is rotated_elsewhere + assert updates == [] + + +def test_reload_with_stale_stored_credential_still_refreshes() -> None: + server = Server(valid_tokens=set()) + stale = make_oauth(expires_in=0) + auth = DiscolikeAuth(stale, reload=lambda: stale) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + + +def test_reload_with_api_key_or_missing_config_still_refreshes() -> None: + server = Server(valid_tokens=set()) + auth = DiscolikeAuth(make_oauth(expires_in=0), reload=lambda: None) + assert sync_client(auth, server).get("/usage").status_code == 200 + assert server.refreshes == 1 + + +def test_reload_adopts_after_401() -> None: + server = Server(valid_tokens={"at-other"}) + rotated_elsewhere = make_oauth(access_token="at-other") + auth = DiscolikeAuth(make_oauth(), reload=lambda: rotated_elsewhere) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.token_calls == [] + assert server.bearers == ["Bearer at-1", "Bearer at-other"] + + +async def test_async_reload_adopts_credential_rotated_by_another_process() -> None: + server = Server(valid_tokens={"at-other"}) + rotated_elsewhere = make_oauth(access_token="at-other") + auth = DiscolikeAuth(make_oauth(expires_in=0), reload=lambda: rotated_elsewhere) + async with async_client(auth, server) as client: + response = await client.get("/usage") + assert response.status_code == 200 + assert server.token_calls == [] + assert server.bearers == ["Bearer at-other"] diff --git a/packages/discolike/tests/test_client.py b/packages/discolike/tests/test_client.py index 09ae2b1..fecf942 100644 --- a/packages/discolike/tests/test_client.py +++ b/packages/discolike/tests/test_client.py @@ -165,3 +165,31 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with AsyncDiscolike(auth=credential, base_url="https://api.test/v1", http_client=http) as client: await client.account.usage() assert seen == ["Bearer at"] + + +def test_client_from_config_reloads_before_refreshing() -> None: + save_credential( + OAuthCredential( + access_token="stale", refresh_token="rt-1", expires_at=0.0, client_id="c", token_endpoint="https://t/token" + ) + ) + seen: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + assert str(request.url) != "https://t/token" + seen.append(request.headers["Authorization"]) + return httpx2.Response(200, json={"requests_mtd": 1}) + + http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + with Discolike(base_url="https://api.test/v1", http_client=http) as client: + save_credential( + OAuthCredential( + access_token="fresh-elsewhere", + refresh_token="rt-2", + expires_at=time.time() + 3600, + client_id="c", + token_endpoint="https://t/token", + ) + ) + client.account.usage() + assert seen == ["Bearer fresh-elsewhere"] diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index 6d0d19d..78b3202 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -120,3 +120,11 @@ def test_resolve_credential_precedence(isolated_config, monkeypatch) -> None: def test_resolve_credential_nothing_raises_with_guidance(isolated_config) -> None: with pytest.raises(AuthenticationError, match="discolike auth login"): resolve_credential() + + +def test_save_config_is_atomic_and_leaves_no_temp_files(isolated_config) -> None: + save_config({"auth_method": "api_key", "api_key": "first"}) + save_config({"auth_method": "api_key", "api_key": "second"}) + assert [entry.name for entry in config_path().parent.iterdir()] == [config_path().name] + assert load_config()["api_key"] == "second" + assert stat.S_IMODE(config_path().stat().st_mode) == 0o600 From 5a48c72109a212f84e6b380ce07c4352de9b0b3e Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 10:41:36 -0700 Subject: [PATCH 34/44] feat(cli): reuse the registered OAuth client across logins Every login registered a fresh DCR client, and PropelAuth remembers consent per client_id, so users saw the consent screen on every `auth login`. The registration (client_id, exact redirect URI, issuer) is now stored under "oauth_client" in the config file, kept across credential writes, and reused when the next login discovers the same issuer and can bind the same loopback port again. PropelAuth matches the redirect URI literally, port included, so a busy port, a different issuer, or an explicit --port that differs all fall back to a fresh registration. `auth logout` still deletes the whole file. --- .../discolike-cli/src/discolike_cli/auth.py | 120 +++++++++++++----- .../discolike-cli/tests/test_auth_oauth.py | 92 +++++++++++++- packages/discolike/src/discolike/_config.py | 21 ++- .../discolike/src/discolike/_credentials.py | 14 ++ packages/discolike/src/discolike/_oauth.py | 2 + packages/discolike/tests/test_config.py | 31 +++++ packages/discolike/tests/test_oauth.py | 3 +- 7 files changed, 243 insertions(+), 40 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index 0dd48c0..fa1c330 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -8,6 +8,7 @@ from datetime import timezone from typing import Any from typing import NoReturn +from urllib.parse import urlparse import httpx2 import typer @@ -19,10 +20,14 @@ from discolike._config import NO_CREDENTIAL_MESSAGE from discolike._config import delete_config from discolike._config import load_credential +from discolike._config import load_oauth_client from discolike._config import save_config from discolike._config import save_credential +from discolike._config import save_oauth_client +from discolike._credentials import OAuthClientRegistration from discolike._credentials import OAuthCredential from discolike._exceptions import AuthenticationError +from discolike._oauth import AuthServerMetadata from discolike._oauth import build_authorization_url from discolike._oauth import discover from discolike._oauth import exchange_code @@ -75,41 +80,92 @@ def _abort_login(message: str) -> NoReturn: raise typer.Exit(code=1) +def _registered_port(registration: OAuthClientRegistration) -> int: + return int(urlparse(registration.redirect_uri).port or RANDOM_PORT) + + +def _reusable_registration(*, issuer: str, port: int) -> OAuthClientRegistration | None: + stored = load_oauth_client() + if stored is None or stored.issuer != issuer: + return None + if port != RANDOM_PORT and _registered_port(stored) != port: + return None + return stored + + +def _bind_stored_port(registration: OAuthClientRegistration) -> CallbackServer | None: + try: + return CallbackServer(port=_registered_port(registration)) + except OSError: + return None + + +def _register_or_reuse( + metadata: AuthServerMetadata, *, port: int, http: httpx2.Client +) -> tuple[CallbackServer, OAuthClientRegistration]: + # PropelAuth matches the redirect URI literally (port included) and remembers consent per client_id, + # so a stored registration is only worth reusing when its exact port can be bound again. + stored = _reusable_registration(issuer=metadata.issuer, port=port) + if stored is not None: + server = _bind_stored_port(stored) + if server is not None: + return server, stored + server = CallbackServer(port=port) + client_id = register_client(metadata, redirect_uris=[server.redirect_uri], client=http) + registration = OAuthClientRegistration( + client_id=client_id, redirect_uri=server.redirect_uri, issuer=metadata.issuer + ) + save_oauth_client(registration) + return server, registration + + +def _authorize( + metadata: AuthServerMetadata, + registration: OAuthClientRegistration, + server: CallbackServer, + *, + resource: str, + open_browser: bool, + http: httpx2.Client, +) -> OAuthCredential: + verifier, challenge = pkce_pair() + state = secrets.token_urlsafe(STATE_BYTES) + url = build_authorization_url( + metadata, + client_id=registration.client_id, + redirect_uri=registration.redirect_uri, + code_challenge=challenge, + state=state, + resource=resource, + ) + print(f"Open this URL in your browser to log in:\n{url}", file=sys.stderr) + if open_browser and not webbrowser.open(url): + print("Could not open a browser; open the URL above manually.", file=sys.stderr) + callback = server.wait(timeout=LOGIN_TIMEOUT_SECONDS) + if callback is None: + _abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login") + if "error" in callback: + _abort_login(f"Authorization failed: {callback.get('error_description') or callback['error']}") + if callback.get("state") != state or "code" not in callback: + _abort_login("Invalid OAuth callback (state mismatch or missing code)") + return exchange_code( + metadata, + client_id=registration.client_id, + code=callback["code"], + code_verifier=verifier, + redirect_uri=registration.redirect_uri, + resource=resource, + client=http, + ) + + def _oauth_login(ctx: typer.Context, *, open_browser: bool, port: int) -> OAuthCredential: base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/") - with httpx2.Client(timeout=OAUTH_HTTP_TIMEOUT_SECONDS) as http, CallbackServer(port=port) as server: + with httpx2.Client(timeout=OAUTH_HTTP_TIMEOUT_SECONDS) as http: metadata = discover(base_url, client=http) - redirect_uri = server.redirect_uri - client_id = register_client(metadata, redirect_uris=[redirect_uri], client=http) - verifier, challenge = pkce_pair() - state = secrets.token_urlsafe(STATE_BYTES) - url = build_authorization_url( - metadata, - client_id=client_id, - redirect_uri=redirect_uri, - code_challenge=challenge, - state=state, - resource=base_url, - ) - print(f"Open this URL in your browser to log in:\n{url}", file=sys.stderr) - if open_browser and not webbrowser.open(url): - print("Could not open a browser; open the URL above manually.", file=sys.stderr) - callback = server.wait(timeout=LOGIN_TIMEOUT_SECONDS) - if callback is None: - _abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login") - if "error" in callback: - _abort_login(f"Authorization failed: {callback.get('error_description') or callback['error']}") - if callback.get("state") != state or "code" not in callback: - _abort_login("Invalid OAuth callback (state mismatch or missing code)") - return exchange_code( - metadata, - client_id=client_id, - code=callback["code"], - code_verifier=verifier, - redirect_uri=redirect_uri, - resource=base_url, - client=http, - ) + server, registration = _register_or_reuse(metadata, port=port, http=http) + with server: + return _authorize(metadata, registration, server, resource=base_url, open_browser=open_browser, http=http) def _api_key_login(ctx: typer.Context, *, api_key: str | None) -> None: diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py index 806a6ff..861a17e 100644 --- a/packages/discolike-cli/tests/test_auth_oauth.py +++ b/packages/discolike-cli/tests/test_auth_oauth.py @@ -2,6 +2,7 @@ import dataclasses import json +import socket import threading import time from collections.abc import Callable @@ -17,7 +18,11 @@ import discolike_cli.auth as auth_module from discolike._config import DEFAULT_BASE_URL from discolike._config import config_path +from discolike._config import load_credential +from discolike._config import load_oauth_client from discolike._config import save_credential +from discolike._config import save_oauth_client +from discolike._credentials import OAuthClientRegistration from discolike._credentials import OAuthCredential from discolike._oauth import AuthServerMetadata from discolike_cli.main import app @@ -29,6 +34,7 @@ authorization_endpoint="https://auth.test/oauth/2.1/authorize", token_endpoint="https://auth.test/oauth/2.1/token", registration_endpoint="https://auth.test/oauth/2.1/register", + issuer="https://auth.test/oauth/2.1", ) CREDENTIAL = OAuthCredential( access_token="at-1", @@ -112,7 +118,9 @@ def test_login_default_runs_oauth_loopback_flow( assert exchange["resource"] == DEFAULT_BASE_URL assert len(provider.opened_urls) == 1 assert build_client_calls == [{"auth": CREDENTIAL}] - assert json.loads(config_path().read_text()) == {"auth_method": "oauth", "oauth": CREDENTIAL.to_config()} + stored = json.loads(config_path().read_text()) + assert (stored["auth_method"], stored["oauth"]) == ("oauth", CREDENTIAL.to_config()) + assert stored["oauth_client"] == {"client_id": "client-1", "redirect_uri": redirect_uri, "issuer": METADATA.issuer} payload = json.loads(result.stderr.splitlines()[-1]) assert payload == {"logged_in": True, "method": "oauth", "expires_at": "2027-01-15T08:00:00+00:00"} assert provider.opened_urls[0] in result.stderr @@ -139,7 +147,7 @@ def test_login_state_mismatch_exits_1_and_saves_nothing( provider.callback_query = lambda query: "code=the-code&state=forged" result = runner.invoke(app, ["auth", "login"]) assert result.exit_code == 1 - assert not config_path().exists() + assert load_credential() is None assert provider.exchange_calls == [] assert json.loads(result.stderr.splitlines()[-1])["error"] == "LoginError" @@ -161,7 +169,7 @@ def test_login_timeout_exits_1( result = runner.invoke(app, ["auth", "login"]) assert result.exit_code == 1 assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"] - assert not config_path().exists() + assert load_credential() is None def test_login_oauth_verify_failure_exits_3_and_saves_nothing( @@ -170,7 +178,7 @@ def test_login_oauth_verify_failure_exits_3_and_saves_nothing( install_build_client(_usage_unauthorized) result = runner.invoke(app, ["auth", "login"]) assert result.exit_code == 3 - assert not config_path().exists() + assert load_credential() is None assert json.loads(result.stderr.splitlines()[-1])["error"] == "AuthenticationError" @@ -210,3 +218,79 @@ def test_status_reports_api_key_method(install_build_client: Callable[[Handler], result = runner.invoke(app, ["--api-key", "dk-abcdefgh1234", "auth", "status"]) assert result.exit_code == 0, result.output assert json.loads(result.stdout)["method"] == "api_key" + + +def _free_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def _registration(port: int, *, issuer: str = METADATA.issuer) -> OAuthClientRegistration: + return OAuthClientRegistration( + client_id="stored-client", redirect_uri=f"http://127.0.0.1:{port}/callback", issuer=issuer + ) + + +def test_login_reuses_stored_client_when_its_port_is_free( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + port = _free_port() + save_oauth_client(_registration(port)) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert provider.register_calls == [] + assert provider.exchange_calls[0]["client_id"] == "stored-client" + assert provider.exchange_calls[0]["redirect_uri"] == f"http://127.0.0.1:{port}/callback" + assert load_oauth_client() == _registration(port) + assert json.loads(config_path().read_text())["auth_method"] == "oauth" + + +def test_login_registers_anew_when_stored_port_is_busy( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + with socket.socket() as blocker: + blocker.bind(("127.0.0.1", 0)) + blocker.listen() + busy_port = blocker.getsockname()[1] + save_oauth_client(_registration(busy_port)) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + new_uri = provider.register_calls[0][0] + assert new_uri != f"http://127.0.0.1:{busy_port}/callback" + stored = load_oauth_client() + assert stored is not None + assert (stored.client_id, stored.redirect_uri) == ("client-1", new_uri) + + +def test_login_registers_anew_for_a_different_issuer( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port(), issuer="https://auth.other/oauth/2.1")) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert len(provider.register_calls) == 1 + stored = load_oauth_client() + assert stored is not None + assert stored.issuer == METADATA.issuer + + +def test_login_explicit_port_differing_from_stored_registers_anew( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + wanted = _free_port() + result = runner.invoke(app, ["auth", "login", "--port", str(wanted)]) + assert result.exit_code == 0, result.output + assert provider.register_calls == [[f"http://127.0.0.1:{wanted}/callback"]] + + +def test_logout_removes_stored_client() -> None: + save_oauth_client(_registration(18484)) + result = runner.invoke(app, ["auth", "logout"]) + assert result.exit_code == 0, result.output + assert load_oauth_client() is None diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index 6544978..f00820e 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -8,6 +8,7 @@ from discolike._credentials import ApiKeyCredential from discolike._credentials import Credential +from discolike._credentials import OAuthClientRegistration from discolike._credentials import OAuthCredential from discolike._exceptions import AuthenticationError @@ -16,6 +17,7 @@ KEYS_URL = "https://app.discolike.com/account/management/keys" AUTH_METHOD_API_KEY = "api_key" AUTH_METHOD_OAUTH = "oauth" +OAUTH_CLIENT_KEY = "oauth_client" NO_CREDENTIAL_MESSAGE = ( "No API key found. Set the DISCOLIKE_API_KEY environment variable, pass api_key=..., " @@ -75,9 +77,22 @@ def load_credential() -> Credential | None: def save_credential(credential: Credential) -> None: if isinstance(credential, OAuthCredential): - save_config({"auth_method": AUTH_METHOD_OAUTH, "oauth": credential.to_config()}) - return - save_config({"auth_method": AUTH_METHOD_API_KEY, "api_key": credential.api_key}) + config: dict[str, Any] = {"auth_method": AUTH_METHOD_OAUTH, "oauth": credential.to_config()} + else: + config = {"auth_method": AUTH_METHOD_API_KEY, "api_key": credential.api_key} + stored_client = load_config().get(OAUTH_CLIENT_KEY) + if stored_client is not None: + config[OAUTH_CLIENT_KEY] = stored_client + save_config(config) + + +def load_oauth_client() -> OAuthClientRegistration | None: + stored = load_config().get(OAUTH_CLIENT_KEY) + return OAuthClientRegistration.from_config(stored) if stored else None + + +def save_oauth_client(registration: OAuthClientRegistration) -> None: + save_config({**load_config(), OAUTH_CLIENT_KEY: registration.to_config()}) def resolve_credential(*, api_key: str | None = None, auth: Credential | None = None) -> Credential: diff --git a/packages/discolike/src/discolike/_credentials.py b/packages/discolike/src/discolike/_credentials.py index 3eed672..c08644a 100644 --- a/packages/discolike/src/discolike/_credentials.py +++ b/packages/discolike/src/discolike/_credentials.py @@ -37,4 +37,18 @@ def to_config(self) -> dict[str, Any]: return asdict(self) +@dataclass(frozen=True) +class OAuthClientRegistration: + client_id: str + redirect_uri: str + issuer: str + + @classmethod + def from_config(cls, data: dict[str, Any]) -> OAuthClientRegistration: + return cls(client_id=str(data["client_id"]), redirect_uri=str(data["redirect_uri"]), issuer=str(data["issuer"])) + + def to_config(self) -> dict[str, Any]: + return asdict(self) + + Credential = ApiKeyCredential | OAuthCredential diff --git a/packages/discolike/src/discolike/_oauth.py b/packages/discolike/src/discolike/_oauth.py index b8243a2..3366873 100644 --- a/packages/discolike/src/discolike/_oauth.py +++ b/packages/discolike/src/discolike/_oauth.py @@ -30,6 +30,7 @@ class AuthServerMetadata: authorization_endpoint: str token_endpoint: str registration_endpoint: str + issuer: str def _b64url(raw: bytes) -> str: @@ -86,6 +87,7 @@ def discover(base_url: str, *, client: httpx2.Client) -> AuthServerMetadata: authorization_endpoint=_require(payload, "authorization_endpoint"), token_endpoint=_require(payload, "token_endpoint"), registration_endpoint=_require(payload, "registration_endpoint"), + issuer=_require(payload, "issuer"), ) diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index 78b3202..314c573 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -6,11 +6,14 @@ from discolike._config import config_path from discolike._config import load_config from discolike._config import load_credential +from discolike._config import load_oauth_client from discolike._config import resolve_api_key from discolike._config import resolve_credential from discolike._config import save_config from discolike._config import save_credential +from discolike._config import save_oauth_client from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthClientRegistration from discolike._credentials import OAuthCredential @@ -128,3 +131,31 @@ def test_save_config_is_atomic_and_leaves_no_temp_files(isolated_config) -> None assert [entry.name for entry in config_path().parent.iterdir()] == [config_path().name] assert load_config()["api_key"] == "second" assert stat.S_IMODE(config_path().stat().st_mode) == 0o600 + + +REGISTRATION = OAuthClientRegistration( + client_id="client-1", redirect_uri="http://127.0.0.1:18484/callback", issuer="https://auth.test/oauth/2.1" +) + + +def test_oauth_client_registration_roundtrip_and_missing(isolated_config) -> None: + assert load_oauth_client() is None + save_oauth_client(REGISTRATION) + assert load_oauth_client() == REGISTRATION + assert load_config()["oauth_client"] == REGISTRATION.to_config() + + +def test_save_credential_preserves_oauth_client(isolated_config) -> None: + save_oauth_client(REGISTRATION) + save_credential(_oauth_credential()) + assert load_oauth_client() == REGISTRATION + assert load_credential() == _oauth_credential() + save_credential(ApiKeyCredential(api_key="dk-1")) + assert load_oauth_client() == REGISTRATION + assert load_credential() == ApiKeyCredential(api_key="dk-1") + + +def test_save_oauth_client_preserves_credential(isolated_config) -> None: + save_credential(_oauth_credential()) + save_oauth_client(REGISTRATION) + assert load_credential() == _oauth_credential() diff --git a/packages/discolike/tests/test_oauth.py b/packages/discolike/tests/test_oauth.py index 3a81042..9f44fc8 100644 --- a/packages/discolike/tests/test_oauth.py +++ b/packages/discolike/tests/test_oauth.py @@ -23,6 +23,7 @@ authorization_endpoint="https://auth.test/oauth/2.1/authorize", token_endpoint="https://auth.test/oauth/2.1/token", registration_endpoint="https://auth.test/oauth/2.1/register", + issuer="https://auth.test/oauth/2.1", ) @@ -50,7 +51,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response( 200, json={ - "issuer": "https://auth.test", + "issuer": METADATA.issuer, "authorization_endpoint": METADATA.authorization_endpoint, "token_endpoint": METADATA.token_endpoint, "registration_endpoint": METADATA.registration_endpoint, From 0bf3b1eb3937305c40c95fef6c32cbc0f31ce3bb Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 10:43:12 -0700 Subject: [PATCH 35/44] docs: fold unreleased 0.3.0 changelog into 0.4.0 --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8213c1..9a8b6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,18 @@ # Changelog -## Unreleased +## 0.4.0 (unreleased) + +Folds in the never-published 0.3.0 changes below. - SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape. - SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced. - CLI: `discolike auth login` now logs in through the browser by default (PKCE authorization-code flow against the platform's OAuth server, loopback redirect on `127.0.0.1`). `--no-browser` prints the URL only, `--port` pins the loopback port for SSH forwarding, and `--method api_key` (or `--api-key KEY`) keeps the API-key flow, including the prompt. Login-flow failures (timeout, denied consent, state mismatch) exit 1 with `{"error": "LoginError", ...}` on stderr. +- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` forgets it. - CLI: `discolike auth status` adds `method` (`api_key` / `oauth`); for OAuth it reports `expires_at` and `expired` instead of a masked key. - SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened. -## 0.3.0 (2026-08-27) +### Request models (previously staged as 0.3.0) - 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. From 091436e5b5e9d6e3fa0456efeca7fc1fef09921c Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 10:54:11 -0700 Subject: [PATCH 36/44] fix(cli): keep the OAuth client registration across logout PropelAuth remembers consent per client_id, so wiping the registration on logout put the consent screen back in front of the user on the next login. The registration is a public PKCE client with no secret, so logout now drops only the credential and leaves "oauth_client" in the config file; the file is removed only when nothing else remains. --- CHANGELOG.md | 2 +- .../discolike-cli/src/discolike_cli/auth.py | 6 ++--- .../discolike-cli/tests/test_auth_oauth.py | 23 +++++++++++++++++-- packages/discolike/src/discolike/_config.py | 9 ++++++++ packages/discolike/tests/test_config.py | 17 ++++++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8b6d8..93ad7c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Folds in the never-published 0.3.0 changes below. - SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape. - SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced. - CLI: `discolike auth login` now logs in through the browser by default (PKCE authorization-code flow against the platform's OAuth server, loopback redirect on `127.0.0.1`). `--no-browser` prints the URL only, `--port` pins the loopback port for SSH forwarding, and `--method api_key` (or `--api-key KEY`) keeps the API-key flow, including the prompt. Login-flow failures (timeout, denied consent, state mismatch) exit 1 with `{"error": "LoginError", ...}` on stderr. -- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` forgets it. +- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` drops the credential but keeps the registration (a public PKCE client, not a secret), so the next login skips consent too. - CLI: `discolike auth status` adds `method` (`api_key` / `oauth`); for OAuth it reports `expires_at` and `expired` instead of a masked key. - SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened. diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index fa1c330..83bece5 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -18,7 +18,7 @@ from discolike._config import DEFAULT_BASE_URL from discolike._config import KEYS_URL from discolike._config import NO_CREDENTIAL_MESSAGE -from discolike._config import delete_config +from discolike._config import delete_credential from discolike._config import load_credential from discolike._config import load_oauth_client from discolike._config import save_config @@ -248,6 +248,6 @@ def status(ctx: typer.Context) -> None: @app.command() @handle_errors def logout() -> None: - """Delete saved credentials from the local config file.""" - delete_config() + """Delete saved credentials from the local config file (the registered OAuth client is kept).""" + delete_credential() emit({"logged_out": True}) diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py index 861a17e..c324ae7 100644 --- a/packages/discolike-cli/tests/test_auth_oauth.py +++ b/packages/discolike-cli/tests/test_auth_oauth.py @@ -22,6 +22,7 @@ from discolike._config import load_oauth_client from discolike._config import save_credential from discolike._config import save_oauth_client +from discolike._credentials import ApiKeyCredential from discolike._credentials import OAuthClientRegistration from discolike._credentials import OAuthCredential from discolike._oauth import AuthServerMetadata @@ -289,8 +290,26 @@ def test_login_explicit_port_differing_from_stored_registers_anew( assert provider.register_calls == [[f"http://127.0.0.1:{wanted}/callback"]] -def test_logout_removes_stored_client() -> None: +def test_logout_keeps_stored_client_and_drops_credential(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) save_oauth_client(_registration(18484)) + save_credential(CREDENTIAL) result = runner.invoke(app, ["auth", "logout"]) assert result.exit_code == 0, result.output - assert load_oauth_client() is None + assert json.loads(result.stdout) == {"logged_out": True} + assert load_credential() is None + assert load_oauth_client() == _registration(18484) + status = runner.invoke(app, ["auth", "status"]) + assert status.exit_code == 3 + assert "discolike auth login" in json.loads(status.stderr)["message"] + + +def test_logout_with_api_key_config_removes_the_key_but_keeps_stored_client() -> None: + save_oauth_client(_registration(18484)) + save_credential(ApiKeyCredential(api_key="dk-1")) + result = runner.invoke(app, ["auth", "logout"]) + assert result.exit_code == 0, result.output + stored = json.loads(config_path().read_text()) + assert "api_key" not in stored + assert "auth_method" not in stored + assert load_oauth_client() == _registration(18484) diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index f00820e..63ef321 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -55,6 +55,15 @@ def delete_config() -> None: config_path().unlink(missing_ok=True) +def delete_credential() -> None: + """Forget the credential but keep the OAuth client registration; it is a public PKCE client, not a secret.""" + stored_client = load_config().get(OAUTH_CLIENT_KEY) + if stored_client is None: + delete_config() + return + save_config({OAUTH_CLIENT_KEY: stored_client}) + + def resolve_api_key(explicit: str | None = None) -> str: if explicit: return explicit diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index 314c573..2a87913 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -4,6 +4,7 @@ from discolike import AuthenticationError from discolike._config import config_path +from discolike._config import delete_credential from discolike._config import load_config from discolike._config import load_credential from discolike._config import load_oauth_client @@ -159,3 +160,19 @@ def test_save_oauth_client_preserves_credential(isolated_config) -> None: save_credential(_oauth_credential()) save_oauth_client(REGISTRATION) assert load_credential() == _oauth_credential() + + +def test_delete_credential_keeps_oauth_client(isolated_config) -> None: + save_oauth_client(REGISTRATION) + save_credential(_oauth_credential()) + delete_credential() + assert load_credential() is None + assert load_oauth_client() == REGISTRATION + assert load_config() == {"oauth_client": REGISTRATION.to_config()} + + +def test_delete_credential_without_oauth_client_removes_file(isolated_config) -> None: + save_credential(ApiKeyCredential(api_key="dk-1")) + delete_credential() + assert not config_path().exists() + delete_credential() From c9354b5029212e36001e80614b9d2964cba4e585 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 10:55:03 -0700 Subject: [PATCH 37/44] fix(sdk): treat a malformed oauth config section as no credential load_config already degrades a corrupt file to an empty dict, but a file with auth_method "oauth" and a missing or malformed "oauth" object (or a malformed "oauth_client") escaped as a raw KeyError/TypeError/ ValueError from the client constructor. Follow the same rule: such a section reads as absent, so callers get the usual "run discolike auth login" AuthenticationError instead of a traceback. --- packages/discolike/src/discolike/_config.py | 11 ++++++++--- packages/discolike/tests/test_config.py | 22 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index 63ef321..2aa438b 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -79,7 +79,10 @@ def resolve_api_key(explicit: str | None = None) -> str: def load_credential() -> Credential | None: config = load_config() if config.get("auth_method") == AUTH_METHOD_OAUTH: - return OAuthCredential.from_config(config["oauth"]) + try: + return OAuthCredential.from_config(config["oauth"]) + except (KeyError, TypeError, ValueError): + return None api_key = config.get("api_key") return ApiKeyCredential(api_key=str(api_key)) if api_key else None @@ -96,8 +99,10 @@ def save_credential(credential: Credential) -> None: def load_oauth_client() -> OAuthClientRegistration | None: - stored = load_config().get(OAUTH_CLIENT_KEY) - return OAuthClientRegistration.from_config(stored) if stored else None + try: + return OAuthClientRegistration.from_config(load_config()[OAUTH_CLIENT_KEY]) + except (KeyError, TypeError, ValueError): + return None def save_oauth_client(registration: OAuthClientRegistration) -> None: diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index 2a87913..8d9a849 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -176,3 +176,25 @@ def test_delete_credential_without_oauth_client_removes_file(isolated_config) -> delete_credential() assert not config_path().exists() delete_credential() + + +@pytest.mark.parametrize( + "config", + [ + {"auth_method": "oauth"}, + {"auth_method": "oauth", "oauth": "not-a-dict"}, + {"auth_method": "oauth", "oauth": {"access_token": "a", "refresh_token": "r"}}, + {"auth_method": "oauth", "oauth": {**_oauth_credential().to_config(), "expires_at": "soon"}}, + ], +) +def test_malformed_oauth_section_is_no_credential(isolated_config, config) -> None: + save_config(config) + assert load_credential() is None + with pytest.raises(AuthenticationError, match="discolike auth login"): + resolve_credential() + + +@pytest.mark.parametrize("stored", ["not-a-dict", {"client_id": "c"}, 7]) +def test_malformed_oauth_client_is_none(isolated_config, stored) -> None: + save_config({"oauth_client": stored}) + assert load_oauth_client() is None From 608185f28651cd686b461a2ae20734813faae8cf Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 11:17:22 -0700 Subject: [PATCH 38/44] fix(cli): re-register when PropelAuth rejects the stored OAuth client A stored client registration that PropelAuth has since forgotten made every login fail the same way: the authorize step came back with invalid_client/unauthorized_client, or the code exchange did, and the CLI kept reusing the dead client_id. Those two error codes now mark the registration as dead; when the registration was a reused one it is discarded, a fresh client is registered, and the browser flow runs once more. A freshly registered client that is rejected, or a second failure, surfaces as the normal LoginError. Other callback errors such as access_denied keep the registration and fail as before. The token endpoint's error code is now carried on OAuthError.error so the CLI can distinguish invalid_client from any other rejection. --- CHANGELOG.md | 2 +- .../discolike-cli/src/discolike_cli/auth.py | 62 ++++++++++---- .../discolike-cli/tests/test_auth_oauth.py | 82 +++++++++++++++++++ packages/discolike/src/discolike/_config.py | 6 ++ packages/discolike/src/discolike/_oauth.py | 20 ++++- packages/discolike/tests/test_config.py | 10 +++ packages/discolike/tests/test_oauth.py | 4 +- 7 files changed, 166 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93ad7c6..49da28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Folds in the never-published 0.3.0 changes below. - SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape. - SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced. - CLI: `discolike auth login` now logs in through the browser by default (PKCE authorization-code flow against the platform's OAuth server, loopback redirect on `127.0.0.1`). `--no-browser` prints the URL only, `--port` pins the loopback port for SSH forwarding, and `--method api_key` (or `--api-key KEY`) keeps the API-key flow, including the prompt. Login-flow failures (timeout, denied consent, state mismatch) exit 1 with `{"error": "LoginError", ...}` on stderr. -- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` drops the credential but keeps the registration (a public PKCE client, not a secret), so the next login skips consent too. +- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` drops the credential but keeps the registration (a public PKCE client, not a secret), so the next login skips consent too. If PropelAuth no longer recognises the stored client (`invalid_client` / `unauthorized_client`), login registers a fresh one and retries once. - CLI: `discolike auth status` adds `method` (`api_key` / `oauth`); for OAuth it reports `expires_at` and `expired` instead of a masked key. - SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened. diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index 83bece5..18d6878 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -19,6 +19,7 @@ from discolike._config import KEYS_URL from discolike._config import NO_CREDENTIAL_MESSAGE from discolike._config import delete_credential +from discolike._config import delete_oauth_client from discolike._config import load_credential from discolike._config import load_oauth_client from discolike._config import save_config @@ -28,6 +29,7 @@ from discolike._credentials import OAuthCredential from discolike._exceptions import AuthenticationError from discolike._oauth import AuthServerMetadata +from discolike._oauth import OAuthError from discolike._oauth import build_authorization_url from discolike._oauth import discover from discolike._oauth import exchange_code @@ -50,6 +52,11 @@ SOURCE_CONFIG = "config" LOGIN_METHODS = (AUTH_METHOD_OAUTH, AUTH_METHOD_API_KEY) +DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"}) + + +class _DeadClientError(Exception): + """The authorization server no longer recognises the registered client_id.""" def _mask(key: str) -> str: @@ -102,21 +109,21 @@ def _bind_stored_port(registration: OAuthClientRegistration) -> CallbackServer | def _register_or_reuse( metadata: AuthServerMetadata, *, port: int, http: httpx2.Client -) -> tuple[CallbackServer, OAuthClientRegistration]: +) -> tuple[CallbackServer, OAuthClientRegistration, bool]: # PropelAuth matches the redirect URI literally (port included) and remembers consent per client_id, # so a stored registration is only worth reusing when its exact port can be bound again. stored = _reusable_registration(issuer=metadata.issuer, port=port) if stored is not None: server = _bind_stored_port(stored) if server is not None: - return server, stored + return server, stored, True server = CallbackServer(port=port) client_id = register_client(metadata, redirect_uris=[server.redirect_uri], client=http) registration = OAuthClientRegistration( client_id=client_id, redirect_uri=server.redirect_uri, issuer=metadata.issuer ) save_oauth_client(registration) - return server, registration + return server, registration, False def _authorize( @@ -145,27 +152,50 @@ def _authorize( if callback is None: _abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login") if "error" in callback: - _abort_login(f"Authorization failed: {callback.get('error_description') or callback['error']}") + message = f"Authorization failed: {callback.get('error_description') or callback['error']}" + if callback["error"] in DEAD_CLIENT_ERRORS: + raise _DeadClientError(message) + _abort_login(message) if callback.get("state") != state or "code" not in callback: _abort_login("Invalid OAuth callback (state mismatch or missing code)") - return exchange_code( - metadata, - client_id=registration.client_id, - code=callback["code"], - code_verifier=verifier, - redirect_uri=registration.redirect_uri, - resource=resource, - client=http, - ) + try: + return exchange_code( + metadata, + client_id=registration.client_id, + code=callback["code"], + code_verifier=verifier, + redirect_uri=registration.redirect_uri, + resource=resource, + client=http, + ) + except OAuthError as exc: + if exc.error in DEAD_CLIENT_ERRORS: + raise _DeadClientError(f"Token exchange failed: {exc}") from exc + raise def _oauth_login(ctx: typer.Context, *, open_browser: bool, port: int) -> OAuthCredential: base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/") with httpx2.Client(timeout=OAUTH_HTTP_TIMEOUT_SECONDS) as http: metadata = discover(base_url, client=http) - server, registration = _register_or_reuse(metadata, port=port, http=http) - with server: - return _authorize(metadata, registration, server, resource=base_url, open_browser=open_browser, http=http) + server, registration, reused = _register_or_reuse(metadata, port=port, http=http) + try: + with server: + return _authorize( + metadata, registration, server, resource=base_url, open_browser=open_browser, http=http + ) + except _DeadClientError as exc: + if not reused: + _abort_login(str(exc)) + delete_oauth_client() + server, registration, _ = _register_or_reuse(metadata, port=port, http=http) + try: + with server: + return _authorize( + metadata, registration, server, resource=base_url, open_browser=open_browser, http=http + ) + except _DeadClientError as exc: + _abort_login(str(exc)) def _api_key_login(ctx: typer.Context, *, api_key: str | None) -> None: diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py index c324ae7..c3baf5b 100644 --- a/packages/discolike-cli/tests/test_auth_oauth.py +++ b/packages/discolike-cli/tests/test_auth_oauth.py @@ -26,6 +26,7 @@ from discolike._credentials import OAuthClientRegistration from discolike._credentials import OAuthCredential from discolike._oauth import AuthServerMetadata +from discolike._oauth import OAuthError from discolike_cli.main import app from discolike_testkit import Handler @@ -63,6 +64,7 @@ def __init__(self) -> None: self.register_calls: list[list[str]] = [] self.exchange_calls: list[dict[str, Any]] = [] self.opened_urls: list[str] = [] + self.exchange_failures: list[OAuthError] = [] self.callback_query: Callable[[dict[str, str]], str] = lambda query: f"code=the-code&state={query['state']}" def discover(self, base_url: str, *, client: httpx2.Client) -> AuthServerMetadata: @@ -75,6 +77,8 @@ def register_client(self, metadata: AuthServerMetadata, *, redirect_uris: list[s def exchange_code(self, metadata: AuthServerMetadata, *, client: httpx2.Client, **kwargs: Any) -> OAuthCredential: self.exchange_calls.append(kwargs) + if self.exchange_failures: + raise self.exchange_failures.pop(0) return CREDENTIAL def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str: @@ -313,3 +317,81 @@ def test_logout_with_api_key_config_removes_the_key_but_keeps_stored_client() -> assert "api_key" not in stored assert "auth_method" not in stored assert load_oauth_client() == _registration(18484) + + +def _dead_then_ok(query: dict[str, str]) -> str: + if query["client_id"] == "stored-client": + return f"error=invalid_client&error_description=unknown+client&state={query['state']}" + return f"code=the-code&state={query['state']}" + + +def test_login_reregisters_when_authorize_rejects_the_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + provider.callback_query = _dead_then_ok + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert len(provider.register_calls) == 1 + assert [call["client_id"] for call in provider.exchange_calls] == ["client-1"] + stored = load_oauth_client() + assert stored is not None + assert (stored.client_id, stored.redirect_uri) == ("client-1", provider.register_calls[0][0]) + assert load_credential() == CREDENTIAL + + +def test_login_reregisters_when_exchange_rejects_the_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + provider.exchange_failures = [OAuthError("invalid_client: gone", error="invalid_client", status_code=400)] + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert [call["client_id"] for call in provider.exchange_calls] == ["stored-client", "client-1"] + assert len(provider.register_calls) == 1 + stored = load_oauth_client() + assert stored is not None + assert stored.client_id == "client-1" + assert load_credential() == CREDENTIAL + + +def test_login_fresh_client_rejected_is_a_login_error_without_retry( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + provider.callback_query = lambda query: f"error=unauthorized_client&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert len(provider.register_calls) == 1 + assert provider.exchange_calls == [] + assert json.loads(result.stderr.splitlines()[-1]) == { + "error": "LoginError", + "message": "Authorization failed: unauthorized_client", + } + + +def test_login_reused_client_rejected_twice_is_a_login_error( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + provider.callback_query = lambda query: f"error=invalid_client&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert len(provider.register_calls) == 1 + assert json.loads(result.stderr.splitlines()[-1])["error"] == "LoginError" + + +def test_login_access_denied_keeps_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + registration = _registration(_free_port()) + save_oauth_client(registration) + provider.callback_query = lambda query: f"error=access_denied&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert provider.register_calls == [] + assert load_oauth_client() == registration diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index 2aa438b..199826f 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -109,6 +109,12 @@ def save_oauth_client(registration: OAuthClientRegistration) -> None: save_config({**load_config(), OAUTH_CLIENT_KEY: registration.to_config()}) +def delete_oauth_client() -> None: + config = load_config() + config.pop(OAUTH_CLIENT_KEY, None) + save_config(config) + + def resolve_credential(*, api_key: str | None = None, auth: Credential | None = None) -> Credential: if auth is not None: return auth diff --git a/packages/discolike/src/discolike/_oauth.py b/packages/discolike/src/discolike/_oauth.py index 3366873..9cf7778 100644 --- a/packages/discolike/src/discolike/_oauth.py +++ b/packages/discolike/src/discolike/_oauth.py @@ -25,6 +25,21 @@ SESSION_EXPIRED_MESSAGE = "OAuth session expired; run `discolike auth login`" +class OAuthError(AuthenticationError): + """An RFC 6749 error body from the authorization server; `error` is its machine-readable code.""" + + def __init__( + self, + message: str, + *, + error: str, + status_code: int | None = None, + payload: Any = None, # noqa: ANN401 -- decoded JSON body, shape is server-defined + ) -> None: + super().__init__(message, status_code=status_code, payload=payload) + self.error = error + + @dataclass(frozen=True) class AuthServerMetadata: authorization_endpoint: str @@ -51,8 +66,9 @@ def _payload(response: httpx2.Response) -> dict[str, Any]: ) from exc if isinstance(payload, dict) and "error" in payload: description = payload.get("error_description") - message = f"{payload['error']}: {description}" if description else str(payload["error"]) - raise AuthenticationError(message, status_code=response.status_code, payload=payload) + error = str(payload["error"]) + message = f"{error}: {description}" if description else error + raise OAuthError(message, error=error, status_code=response.status_code, payload=payload) if response.status_code >= 400 or not isinstance(payload, dict): raise AuthenticationError( f"OAuth server returned HTTP {response.status_code}", status_code=response.status_code, payload=payload diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index 8d9a849..ab0ed25 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -5,6 +5,7 @@ from discolike import AuthenticationError from discolike._config import config_path from discolike._config import delete_credential +from discolike._config import delete_oauth_client from discolike._config import load_config from discolike._config import load_credential from discolike._config import load_oauth_client @@ -198,3 +199,12 @@ def test_malformed_oauth_section_is_no_credential(isolated_config, config) -> No def test_malformed_oauth_client_is_none(isolated_config, stored) -> None: save_config({"oauth_client": stored}) assert load_oauth_client() is None + + +def test_delete_oauth_client_keeps_credential(isolated_config) -> None: + save_credential(_oauth_credential()) + save_oauth_client(REGISTRATION) + delete_oauth_client() + assert load_oauth_client() is None + assert load_credential() == _oauth_credential() + delete_oauth_client() diff --git a/packages/discolike/tests/test_oauth.py b/packages/discolike/tests/test_oauth.py index 9f44fc8..38a85a3 100644 --- a/packages/discolike/tests/test_oauth.py +++ b/packages/discolike/tests/test_oauth.py @@ -11,6 +11,7 @@ from discolike._credentials import OAuthCredential from discolike._oauth import CLIENT_NAME from discolike._oauth import AuthServerMetadata +from discolike._oauth import OAuthError from discolike._oauth import build_authorization_url from discolike._oauth import discover from discolike._oauth import exchange_code @@ -170,7 +171,7 @@ def test_oauth_error_body_maps_to_authentication_error() -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(400, json={"error": "invalid_grant", "error_description": "code expired"}) - with pytest.raises(AuthenticationError, match="invalid_grant: code expired") as exc_info: + with pytest.raises(OAuthError, match="invalid_grant: code expired") as exc_info: exchange_code( METADATA, client_id="c", @@ -181,6 +182,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: client=client_for(handler), ) assert exc_info.value.status_code == 400 + assert exc_info.value.error == "invalid_grant" def test_non_json_error_maps_to_authentication_error() -> None: From fa9edb4698ca6f385892c27e648fef20da9f11fa Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 11:26:42 -0700 Subject: [PATCH 39/44] =?UTF-8?q?chore:=20version=20is=200.3.0=20=E2=80=94?= =?UTF-8?q?=20the=20unreleased=200.3.0=20absorbs=20OAuth,=20no=20separate?= =?UTF-8?q?=200.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++---- packages/discolike-cli/pyproject.toml | 4 ++-- packages/discolike/pyproject.toml | 2 +- packages/discolike/src/discolike/_version.py | 2 +- packages/discolike/tests/test_package.py | 2 +- uv.lock | 2 +- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49da28f..e5b91fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,6 @@ # Changelog -## 0.4.0 (unreleased) - -Folds in the never-published 0.3.0 changes below. +## 0.3.0 (unreleased) - SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape. - SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced. @@ -12,7 +10,7 @@ Folds in the never-published 0.3.0 changes below. - SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened. -### Request models (previously staged as 0.3.0) +### Request models - 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. diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index 00418ff..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.4.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.4.0", + "discolike==0.3.0", "typer>=0.12", "rich>=13.0", ] diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 197da96..cb245d2 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.4.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 6a9beea..493f741 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.3.0" diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index 5700221..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.4.0" + assert discolike.__version__ == "0.3.0" diff --git a/uv.lock b/uv.lock index 8b85bde..2de2feb 100644 --- a/uv.lock +++ b/uv.lock @@ -192,7 +192,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.4.0" +version = "0.3.0" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" }, From b00f9d0b2067a74adceaa2374638fa8855a8e12f Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 11:47:52 -0700 Subject: [PATCH 40/44] chore(sdk): note the planned switch to authlib once it ships httpx2 support --- packages/discolike/src/discolike/_auth.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/discolike/src/discolike/_auth.py b/packages/discolike/src/discolike/_auth.py index e85ba50..e77c2ae 100644 --- a/packages/discolike/src/discolike/_auth.py +++ b/packages/discolike/src/discolike/_auth.py @@ -18,6 +18,9 @@ from discolike._oauth import parse_refresh_response from discolike._oauth import refresh_request +# TODO: replace this module and _oauth.py with authlib's httpx2 OAuth2Client once a release +# includes authlib/authlib@e4fb941 (httpx2 support merged 2026-08-27; 1.7.2 predates it). + API_KEY_HEADER = "X-discolike-key" UNAUTHORIZED = 401 From 45b2b469429c52daca6465684fbf9a39b8979551 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 11:52:44 -0700 Subject: [PATCH 41/44] refactor(sdk): drop unused _oauth.refresh helper --- packages/discolike/src/discolike/_oauth.py | 4 ---- packages/discolike/tests/test_oauth.py | 9 ++++++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/discolike/src/discolike/_oauth.py b/packages/discolike/src/discolike/_oauth.py index 9cf7778..c7220b8 100644 --- a/packages/discolike/src/discolike/_oauth.py +++ b/packages/discolike/src/discolike/_oauth.py @@ -184,7 +184,3 @@ def parse_refresh_response(response: httpx2.Response, *, credential: OAuthCreden token_endpoint=credential.token_endpoint, fallback_refresh_token=credential.refresh_token, ) - - -def refresh(credential: OAuthCredential, *, client: httpx2.Client) -> OAuthCredential: - return parse_refresh_response(client.send(refresh_request(credential)), credential=credential) diff --git a/packages/discolike/tests/test_oauth.py b/packages/discolike/tests/test_oauth.py index 38a85a3..26b8f26 100644 --- a/packages/discolike/tests/test_oauth.py +++ b/packages/discolike/tests/test_oauth.py @@ -15,8 +15,9 @@ from discolike._oauth import build_authorization_url from discolike._oauth import discover from discolike._oauth import exchange_code +from discolike._oauth import parse_refresh_response from discolike._oauth import pkce_pair -from discolike._oauth import refresh +from discolike._oauth import refresh_request from discolike._oauth import register_client BASE_URL = "https://api.test/v1" @@ -207,7 +208,8 @@ def rotating(request: httpx2.Request) -> httpx2.Response: seen.append(request) return httpx2.Response(200, json={"access_token": "new", "refresh_token": "rt-new", "expires_in": 60}) - rotated = refresh(credential, client=client_for(rotating)) + with client_for(rotating) as client: + rotated = parse_refresh_response(client.send(refresh_request(credential)), credential=credential) assert form(seen[0]) == {"grant_type": "refresh_token", "refresh_token": "rt-old", "client_id": "c"} assert (rotated.access_token, rotated.refresh_token) == ("new", "rt-new") assert rotated.client_id == "c" @@ -216,7 +218,8 @@ def rotating(request: httpx2.Request) -> httpx2.Response: def not_rotating(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"access_token": "newer", "expires_in": 60}) - kept = refresh(rotated, client=client_for(not_rotating)) + with client_for(not_rotating) as client: + kept = parse_refresh_response(client.send(refresh_request(rotated)), credential=rotated) assert (kept.access_token, kept.refresh_token) == ("newer", "rt-new") From b2d5e3ea9122d6f4728a9d71b3e9e5a761723963 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 11:54:01 -0700 Subject: [PATCH 42/44] refactor(sdk): drop resolve_api_key, superseded by resolve_credential --- packages/discolike/src/discolike/_config.py | 12 ------------ packages/discolike/tests/test_config.py | 18 ------------------ 2 files changed, 30 deletions(-) diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index 199826f..12924fe 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -64,18 +64,6 @@ def delete_credential() -> None: save_config({OAUTH_CLIENT_KEY: stored_client}) -def resolve_api_key(explicit: str | None = None) -> str: - if explicit: - return explicit - from_env = os.environ.get(ENV_API_KEY) - if from_env: - return from_env - from_file = load_config().get("api_key") - if from_file: - return str(from_file) - raise AuthenticationError(NO_CREDENTIAL_MESSAGE) - - def load_credential() -> Credential | None: config = load_config() if config.get("auth_method") == AUTH_METHOD_OAUTH: diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index ab0ed25..5daa832 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -9,7 +9,6 @@ from discolike._config import load_config from discolike._config import load_credential from discolike._config import load_oauth_client -from discolike._config import resolve_api_key from discolike._config import resolve_credential from discolike._config import save_config from discolike._config import save_credential @@ -47,22 +46,6 @@ def test_load_missing_returns_empty(isolated_config) -> None: assert load_config() == {} -def test_resolve_precedence_explicit_wins(isolated_config, monkeypatch) -> None: - save_config({"auth_method": "api_key", "api_key": "from-file"}) - monkeypatch.setenv("DISCOLIKE_API_KEY", "from-env") - assert resolve_api_key("explicit") == "explicit" - assert resolve_api_key(None) == "from-env" - monkeypatch.delenv("DISCOLIKE_API_KEY") - assert resolve_api_key(None) == "from-file" - - -def test_resolve_nothing_raises_with_guidance(isolated_config) -> None: - with pytest.raises(AuthenticationError) as exc_info: - resolve_api_key(None) - assert "DISCOLIKE_API_KEY" in str(exc_info.value) - assert "discolike auth login" in str(exc_info.value) - - def test_corrupt_config_returns_empty(isolated_config) -> None: config_path().parent.mkdir(parents=True, exist_ok=True) config_path().write_text("{not json") @@ -98,7 +81,6 @@ def test_save_and_load_oauth_credential(isolated_config) -> None: def test_save_api_key_credential_keeps_legacy_shape(isolated_config) -> None: save_credential(ApiKeyCredential(api_key="dk-1")) assert load_config() == {"auth_method": "api_key", "api_key": "dk-1"} - assert resolve_api_key(None) == "dk-1" def test_load_credential_without_auth_method_is_api_key(isolated_config) -> None: From 05485360fc6cee15a8f0a3fd32902ec272977fc1 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 28 Aug 2026 12:03:36 -0700 Subject: [PATCH 43/44] fix(auth): check callback state before acting on an error, redact tokens from error payloads A forged /callback?error=invalid_client from anything that can reach the loopback port could evict the stored client registration before the state check ran. State is now verified first, so only the browser session the CLI started can affect login state. Token-response parsing errors attached the raw response, access token included, as exc.payload; SDK consumers that log payloads would leak a live token. Token fields are stripped before the exception is raised. --- .../discolike-cli/src/discolike_cli/auth.py | 6 ++++-- packages/discolike-cli/tests/test_auth_oauth.py | 14 ++++++++++++++ packages/discolike/src/discolike/_oauth.py | 16 +++++++++++++--- packages/discolike/tests/test_oauth.py | 17 +++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index 18d6878..597385d 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -151,13 +151,15 @@ def _authorize( callback = server.wait(timeout=LOGIN_TIMEOUT_SECONDS) if callback is None: _abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login") + if callback.get("state") != state: + _abort_login("Invalid OAuth callback (state mismatch)") if "error" in callback: message = f"Authorization failed: {callback.get('error_description') or callback['error']}" if callback["error"] in DEAD_CLIENT_ERRORS: raise _DeadClientError(message) _abort_login(message) - if callback.get("state") != state or "code" not in callback: - _abort_login("Invalid OAuth callback (state mismatch or missing code)") + if "code" not in callback: + _abort_login("Invalid OAuth callback (missing code)") try: return exchange_code( metadata, diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py index c3baf5b..a241008 100644 --- a/packages/discolike-cli/tests/test_auth_oauth.py +++ b/packages/discolike-cli/tests/test_auth_oauth.py @@ -395,3 +395,17 @@ def test_login_access_denied_keeps_stored_client( assert result.exit_code == 1 assert provider.register_calls == [] assert load_oauth_client() == registration + + +def test_login_forged_error_callback_cannot_evict_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + registration = _registration(_free_port()) + save_oauth_client(registration) + provider.callback_query = lambda query: "error=invalid_client&state=forged" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert "state mismatch" in json.loads(result.stderr.splitlines()[-1])["message"] + assert provider.register_calls == [] + assert load_oauth_client() == registration diff --git a/packages/discolike/src/discolike/_oauth.py b/packages/discolike/src/discolike/_oauth.py index c7220b8..fb295b2 100644 --- a/packages/discolike/src/discolike/_oauth.py +++ b/packages/discolike/src/discolike/_oauth.py @@ -22,6 +22,7 @@ PKCE_METHOD = "S256" PKCE_VERIFIER_BYTES = 32 TOKEN_HEADERS = {"Accept": "application/json"} +TOKEN_KEYS = frozenset({"access_token", "refresh_token", "id_token"}) SESSION_EXPIRED_MESSAGE = "OAuth session expired; run `discolike auth login`" @@ -82,16 +83,25 @@ def _require(payload: dict[str, Any], key: str) -> str: return str(payload[key]) +def _redacted(payload: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in payload.items() if key not in TOKEN_KEYS} + + def _credential_from_token_payload( payload: dict[str, Any], *, client_id: str, token_endpoint: str, fallback_refresh_token: str | None ) -> OAuthCredential: + # Exceptions raised here may be logged by SDK consumers; never attach live tokens to them. + safe_payload = _redacted(payload) refresh_token = payload.get("refresh_token") or fallback_refresh_token if not refresh_token: - raise AuthenticationError("OAuth token response has no `refresh_token`", payload=payload) + raise AuthenticationError("OAuth token response has no `refresh_token`", payload=safe_payload) + for key in ("access_token", "expires_in"): + if key not in payload: + raise AuthenticationError(f"OAuth server response is missing `{key}`", payload=safe_payload) return OAuthCredential( - access_token=_require(payload, "access_token"), + access_token=str(payload["access_token"]), refresh_token=str(refresh_token), - expires_at=time.time() + float(_require(payload, "expires_in")), + expires_at=time.time() + float(payload["expires_in"]), client_id=client_id, token_endpoint=token_endpoint, ) diff --git a/packages/discolike/tests/test_oauth.py b/packages/discolike/tests/test_oauth.py index 26b8f26..daa4fb0 100644 --- a/packages/discolike/tests/test_oauth.py +++ b/packages/discolike/tests/test_oauth.py @@ -168,6 +168,23 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) +def test_malformed_token_response_error_payload_carries_no_tokens() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"access_token": "at", "refresh_token": "rt", "token_type": "Bearer"}) + + with pytest.raises(AuthenticationError, match="expires_in") as info: + exchange_code( + METADATA, + client_id="c", + code="x", + code_verifier="v", + redirect_uri="http://127.0.0.1:1/callback", + resource=BASE_URL, + client=client_for(handler), + ) + assert info.value.payload == {"token_type": "Bearer"} + + def test_oauth_error_body_maps_to_authentication_error() -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(400, json={"error": "invalid_grant", "error_description": "code expired"}) From c540e2fc84731d9508a9337883dbab9c6ab4f1cb Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Sat, 29 Aug 2026 15:16:18 -0700 Subject: [PATCH 44/44] release: 0.3.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b91fe..f2aedd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.3.0 (unreleased) +## 0.3.0 (2026-08-29) - SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape. - SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced.