diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cf64f0..73a73ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.11', '3.12', '3.13', '3.14'] + python-version: ['3.11', '3.12', '3.13', '3.14', '3.15'] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 @@ -34,7 +34,7 @@ jobs: matrix: include: - resolution: highest - python-version: '3.14' + python-version: '3.15' - resolution: lowest-direct python-version: '3.11' steps: @@ -47,17 +47,3 @@ jobs: - run: uv run --no-sync pytest packages/discolike-cli/tests -q - run: uv build --all-packages -o dist - run: uv run --no-project --isolated --resolution ${{ matrix.resolution }} --find-links dist --with dist/discolike_cli-*.whl discolike --version - - # 3.15 is still prerelease; non-blocking until 3.15.0 final, when it moves - # into the matrix above and gets a classifier. - test-prerelease: - runs-on: ubuntu-latest - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - with: - python-version: '3.15' - - run: uv sync --all-packages - - run: uv run pytest packages/discolike/tests -q - - run: uv run pytest packages/discolike-cli/tests -q diff --git a/CHANGELOG.md b/CHANGELOG.md index 98473af..de9e3a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.6.2 (2026-10-21) + +- SDK/CLI: Python 3.15 is supported and tested in CI. +- SDK/CLI: an LLM integration's `model_name` can carry provider settings after `#`, as URL query parameters: `region` for `bedrock` and `bedrock_mantle` (any AWS region, optional), `account` for `snowflake` (account identifier, with a programmatic access token as `api_key`) and `region` plus `project` for `watsonx`, e.g. `us.anthropic.claude-sonnet-4-5-20250929-v1:0#region=us-east-1`. Snowflake and watsonx become usable as providers. An unknown setting or region is a 400 (`ValidationError`). `DiscogenModels.provider_regions` lists the accepted regions per provider. The CLI's `llm-providers create`, `update` and `test-connection` gain `--region`, `--account` and `--project`, which build the suffix for you, and `discogen models` output gains a `provider_regions` key. It needs an API release that knows the settings; older SDKs can already send the suffix in `model_name`. +- SDK: `BulkContactMatchQueryItem` gains `linkedin`, a LinkedIn profile URL or username matched exactly before `email`, then name and company, so a list of profile URLs alone returns the matching contacts. It needs an API release that knows the field. +- SDK (note for maintainers): `discolike/_generated/requests.py` is regenerated from the dev spec, which adds `linkedin` and moves `api_key` after `base_url` on the LLM provider request models (keyword arguments, so no caller change). + ## 0.6.1 (2026-10-19) - SDK/CLI: discover's server-side default for `min_digital_footprint` drops from 50 to 25 when the field is omitted, so lists include more real companies with a thin web presence. Pass `min_digital_footprint=50` (CLI `--min-digital-footprint 50`) to keep the old floor. No SDK change; older versions get the new default too. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eae594e..4afdc31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ uv run pytest packages/discolike/tests -q uv run pytest packages/discolike-cli/tests -q ``` -CI runs the same checks across Python 3.11–3.14. +CI runs the same checks across Python 3.11–3.15. ## Branches diff --git a/examples/discover_and_enrich.py b/examples/discover_and_enrich.py index 6cdff84..6f6447c 100644 --- a/examples/discover_and_enrich.py +++ b/examples/discover_and_enrich.py @@ -54,7 +54,7 @@ def main() -> None: print(f"\nRunning DiscoGen over {len(domains)} domains: {args.query!r}") 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}%")) + status = job.wait(max_wait=args.timeout, on_poll=lambda s: print(f" status={s.status} progress={s.progress}%")) print("\nEnriched results:") for row in status.results or []: diff --git a/examples/enrich_crm_export.py b/examples/enrich_crm_export.py index 6322a9f..e30406f 100644 --- a/examples/enrich_crm_export.py +++ b/examples/enrich_crm_export.py @@ -47,9 +47,9 @@ def enrich_row(client: Discolike, *, domain: str) -> dict[str, str]: "dl_name": profile.name or "", "dl_employees": profile.employees or "", "dl_revenue_range": profile.revenue_range or "", - "dl_country": address.country if address else "", - "dl_state": address.state if address else "", - "dl_city": address.city if address else "", + "dl_country": (address.country if address else None) or "", + "dl_state": (address.state if address else None) or "", + "dl_city": (address.city if address else None) or "", "dl_industry_groups": top_keys(profile.industry_groups), "dl_business_model": top_keys(profile.business_model), "dl_description": profile.description or "", diff --git a/examples/find_emails_from_csv.py b/examples/find_emails_from_csv.py index 228ca6b..930e71b 100644 --- a/examples/find_emails_from_csv.py +++ b/examples/find_emails_from_csv.py @@ -68,7 +68,7 @@ def main() -> None: 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(FindEmailBatchRequest.model_validate({"requests": chunk})) - results = batch.results(timeout=args.timeout) + results = batch.results(max_wait=args.timeout) for item in results.results: output = item.result if not isinstance(output, EnumerationOutput): diff --git a/examples/match_crm_contacts.py b/examples/match_crm_contacts.py index 55fa8e4..dd49a2f 100644 --- a/examples/match_crm_contacts.py +++ b/examples/match_crm_contacts.py @@ -208,7 +208,7 @@ def main() -> None: job = client.contacts.bulk_match( BulkContactMatchRequest.model_validate({"queries": queries, "limit": 1}) ) - status = job.wait(timeout=args.timeout) + status = job.wait(max_wait=args.timeout) except Exception as exc: # leave the chunk un-checkpointed so a rerun retries it failed_chunks += 1 print(f" ! {label} failed ({exc}); rows left pending, rerun to retry") diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index c25eae5..23b9182 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.6.1" +version = "0.6.2" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" @@ -12,7 +12,7 @@ license-files = ["LICENSE"] requires-python = ">=3.11" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ - "discolike==0.6.1", + "discolike==0.6.2", "typer>=0.26.1,<0.28", "rich>=13.0", ] @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: 3 :: Only", "Operating System :: OS Independent", "Environment :: Console", diff --git a/packages/discolike-cli/src/discolike_cli/providers.py b/packages/discolike-cli/src/discolike_cli/providers.py index 93d8701..0f9e767 100644 --- a/packages/discolike-cli/src/discolike_cli/providers.py +++ b/packages/discolike-cli/src/discolike_cli/providers.py @@ -1,5 +1,8 @@ from __future__ import annotations +from urllib.parse import parse_qsl +from urllib.parse import urlencode + import typer from discolike.requests import LLMProviderCreateRequest @@ -15,6 +18,23 @@ search_providers_app = typer.Typer(help="Manage BYOK web search provider integrations (Tavily, Serper, etc.).") llm_providers_app = typer.Typer(help="Manage BYOK LLM provider integrations (OpenAI, Anthropic, custom endpoints).") +LLM_PROVIDER_HELP = "LLM provider key, e.g. openai, anthropic, bedrock, snowflake, watsonx, or custom." +REGION_HELP = ( + "Provider region: an AWS region for bedrock and bedrock_mantle, a watsonx region (us-south, eu-de, ...) " + "for watsonx. `discogen models` lists them under provider_regions." +) +ACCOUNT_HELP = "Snowflake account identifier, e.g. orgname-accountname (snowflake only)." +PROJECT_HELP = "watsonx project ID (watsonx only)." + + +def _with_model_settings(model_name: str, *, region: str | None, account: str | None, project: str | None) -> str: + """The API reads provider settings from model_name after "#", e.g. "#region=us-east-1".""" + flags = {key: value for key, value in (("region", region), ("account", account), ("project", project)) if value} + if not flags: + return model_name + base, _, existing = model_name.partition("#") + return f"{base}#{urlencode(dict(parse_qsl(existing)) | flags)}" + @search_providers_app.command("list") @handle_errors @@ -149,10 +169,13 @@ def llm_list_command( def llm_create_command( ctx: typer.Context, name: str = typer.Option(..., "--name", help="User-friendly name for the integration."), - provider: str = typer.Option(..., "--provider", help="LLM provider name: openai, anthropic, or custom."), + provider: str = typer.Option(..., "--provider", help=LLM_PROVIDER_HELP), api_key: str = typer.Option(..., "--api-key", help="API key for the provider."), model_name: str = typer.Option(..., "--model-name", help="Default model name for this integration."), base_url: str | None = typer.Option(None, "--base-url", help="Endpoint URL (required for the custom provider)."), + region: str | None = typer.Option(None, "--region", help=REGION_HELP), + account: str | None = typer.Option(None, "--account", help=ACCOUNT_HELP), + project: str | None = typer.Option(None, "--project", help=PROJECT_HELP), ) -> None: """Create an LLM provider integration.""" from discolike_cli.main import get_client @@ -160,7 +183,12 @@ def llm_create_command( request = build_request( LLMProviderCreateRequest, _merge_params( - None, integration_name=name, provider=provider, api_key=api_key, model_name=model_name, base_url=base_url + None, + integration_name=name, + provider=provider, + api_key=api_key, + model_name=_with_model_settings(model_name, region=region, account=account, project=project), + base_url=base_url, ), ) emit(get_client(ctx).llm_providers.create(request)) @@ -185,10 +213,13 @@ def llm_update_command( ctx: typer.Context, integration_id: str = typer.Argument(..., help="ID of the LLM provider integration to update."), name: str = typer.Option(..., "--name", help="User-friendly name for the integration."), - provider: str = typer.Option(..., "--provider", help="LLM provider name: openai, anthropic, or custom."), + provider: str = typer.Option(..., "--provider", help=LLM_PROVIDER_HELP), model_name: str = typer.Option(..., "--model-name", help="Default model name for this integration."), api_key: str | None = typer.Option(None, "--api-key", help="New API key (omit to keep the stored key)."), base_url: str | None = typer.Option(None, "--base-url", help="Endpoint URL (required for the custom provider)."), + region: str | None = typer.Option(None, "--region", help=REGION_HELP), + account: str | None = typer.Option(None, "--account", help=ACCOUNT_HELP), + project: str | None = typer.Option(None, "--project", help=PROJECT_HELP), ) -> None: """Update an LLM provider integration.""" from discolike_cli.main import get_client @@ -196,7 +227,13 @@ def llm_update_command( request = build_request( LLMProviderUpdateRequest, { - **_merge_params(None, integration_name=name, provider=provider, model_name=model_name, base_url=base_url), + **_merge_params( + None, + integration_name=name, + provider=provider, + model_name=_with_model_settings(model_name, region=region, account=account, project=project), + base_url=base_url, + ), "api_key": api_key, }, ) @@ -233,10 +270,13 @@ def llm_set_default_command( def llm_test_connection_command( ctx: typer.Context, name: str = typer.Option("cli-test", "--name", help="Name to label the test configuration."), - provider: str = typer.Option(..., "--provider", help="LLM provider name: openai, anthropic, or custom."), + provider: str = typer.Option(..., "--provider", help=LLM_PROVIDER_HELP), api_key: str = typer.Option(..., "--api-key", help="API key for the provider."), model_name: str = typer.Option(..., "--model-name", help="Model name to test against."), base_url: str | None = typer.Option(None, "--base-url", help="Endpoint URL (required for the custom provider)."), + region: str | None = typer.Option(None, "--region", help=REGION_HELP), + account: str | None = typer.Option(None, "--account", help=ACCOUNT_HELP), + project: str | None = typer.Option(None, "--project", help=PROJECT_HELP), ) -> None: """Test a provider configuration before saving it.""" from discolike_cli.main import get_client @@ -244,7 +284,12 @@ def llm_test_connection_command( request = build_request( LLMProviderCreateRequest, _merge_params( - None, integration_name=name, provider=provider, api_key=api_key, model_name=model_name, base_url=base_url + None, + integration_name=name, + provider=provider, + api_key=api_key, + model_name=_with_model_settings(model_name, region=region, account=account, project=project), + base_url=base_url, ), ) emit(get_client(ctx).llm_providers.test_connection(request)) diff --git a/packages/discolike-cli/tests/test_discogen_cli.py b/packages/discolike-cli/tests/test_discogen_cli.py index 7095e79..957339a 100644 --- a/packages/discolike-cli/tests/test_discogen_cli.py +++ b/packages/discolike-cli/tests/test_discogen_cli.py @@ -314,7 +314,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: install_build_client(handler) result = runner.invoke(app, ["discogen", "models"]) assert result.exit_code == 0, result.output - assert json.loads(result.stdout) == {"models": {"openai": [{"name": "gpt-5.4", "supports_web_search": False}]}} + assert json.loads(result.stdout) == { + "models": {"openai": [{"name": "gpt-5.4", "supports_web_search": False}]}, + "provider_regions": {}, + } def test_discogen_status_default_family_discogen(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_providers_cli.py b/packages/discolike-cli/tests/test_providers_cli.py index b365d5a..45fa8e3 100644 --- a/packages/discolike-cli/tests/test_providers_cli.py +++ b/packages/discolike-cli/tests/test_providers_cli.py @@ -138,6 +138,71 @@ def handler(request: httpx2.Request) -> httpx2.Response: } +def test_llm_providers_create_appends_provider_settings_to_model_name( + install_build_client: Callable[[Handler], None], +) -> None: + captured: dict[str, dict[str, str]] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + captured["body"] = json.loads(request.content) + return httpx2.Response(200, json={"message": "created", "integration_id": "llm1"}) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "llm-providers", + "create", + "--name", + "watsonx", + "--provider", + "watsonx", + "--api-key", + "ibm-key", + "--model-name", + "watsonx/ibm/granite-3-8b-instruct", + "--region", + "eu-de", + "--project", + "11111111-2222-3333-4444-555555555555", + ], + ) + assert result.exit_code == 0, result.output + assert captured["body"]["model_name"] == ( + "watsonx/ibm/granite-3-8b-instruct#region=eu-de&project=11111111-2222-3333-4444-555555555555" + ) + + +def test_llm_providers_update_flags_override_an_existing_settings_suffix( + install_build_client: Callable[[Handler], None], +) -> None: + captured: dict[str, dict[str, str]] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + captured["body"] = json.loads(request.content) + return httpx2.Response(200, json={"message": "updated"}) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "llm-providers", + "update", + "llm1", + "--name", + "watsonx", + "--provider", + "watsonx", + "--model-name", + "watsonx/ibm/granite-3-8b-instruct#region=us-south&project=p1", + "--region", + "eu-de", + ], + ) + assert result.exit_code == 0, result.output + assert captured["body"]["model_name"] == "watsonx/ibm/granite-3-8b-instruct#region=eu-de&project=p1" + + def test_llm_providers_update_keeps_null_api_key(install_build_client: Callable[[Handler], None]) -> None: captured: dict[str, object] = {} diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 507ad99..0223fa3 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: 3 :: Only", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", @@ -33,7 +34,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.6.1"] +cli = ["discolike-cli==0.6.2"] [project.urls] Homepage = "https://www.discolike.com" diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index a519c7d..3749f6b 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2905,7 +2905,6 @@ class LLMProviderCreateRequest(DiscolikeRequest): 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"), @@ -2917,6 +2916,7 @@ class LLMProviderCreateRequest(DiscolikeRequest): title="Base Url", ), ] = None + api_key: Annotated[str, Field(description="API key for the provider", title="Api Key")] class LLMProviderUpdateRequest(DiscolikeRequest): @@ -2934,13 +2934,6 @@ class LLMProviderUpdateRequest(DiscolikeRequest): 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"), @@ -2952,6 +2945,13 @@ class LLMProviderUpdateRequest(DiscolikeRequest): title="Base Url", ), ] = None + api_key: Annotated[ + str | None, + Field( + description="API key for the provider (null to keep existing)", + title="Api Key", + ), + ] class SearchProviderRequest(DiscolikeRequest): @@ -3084,7 +3084,7 @@ class BulkContactMatchQueryItem(DiscolikeRequest): name: Annotated[ str | None, Field( - description="Person name to search for. Required unless email is provided.", + description="Person name to search for. Required unless email or linkedin is provided.", title="Name", ), ] = None @@ -3095,6 +3095,13 @@ class BulkContactMatchQueryItem(DiscolikeRequest): title="Email", ), ] = None + linkedin: Annotated[ + str | None, + Field( + description="LinkedIn profile URL or username for exact lookup, tried before email. When found, email and name/company matching are skipped for this row.", + title="Linkedin", + ), + ] = None company_name: Annotated[ str | None, Field(description="Company name to narrow the search.", title="Company Name"), diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index 43c4ab0..22049ab 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.6.1" +__version__ = "0.6.2" diff --git a/packages/discolike/src/discolike/resources/discogen.py b/packages/discolike/src/discolike/resources/discogen.py index d6719bd..ce0e455 100644 --- a/packages/discolike/src/discolike/resources/discogen.py +++ b/packages/discolike/src/discolike/resources/discogen.py @@ -46,6 +46,10 @@ class DiscogenModelInfo(DiscolikeModel): class DiscogenModels(DiscolikeModel): models: dict[str, list[DiscogenModelInfo]] = pydantic.Field(default_factory=dict) + provider_regions: dict[str, list[str]] = pydantic.Field( + default_factory=dict, + description='Regions each regional provider accepts; pick one with "#region=" in model_name.', + ) class DiscogenResource(SyncAPIResource): diff --git a/packages/discolike/tests/test_discogen.py b/packages/discolike/tests/test_discogen.py index 20423a7..cba36eb 100644 --- a/packages/discolike/tests/test_discogen.py +++ b/packages/discolike/tests/test_discogen.py @@ -140,7 +140,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: "models": { "xai": [{"name": "grok-4", "supports_web_search": True}], "openai": [{"name": "gpt-5.4", "supports_web_search": False}], - } + }, + "provider_regions": {"bedrock": ["us-east-1", "us-west-2"]}, }, ) @@ -152,6 +153,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert result.models["xai"][0].name == "grok-4" assert result.models["xai"][0].supports_web_search is True assert result.models["openai"][0].name == "gpt-5.4" + assert result.provider_regions == {"bedrock": ["us-east-1", "us-west-2"]} def test_job_reattaches_without_http_call(make_client: ClientFactory) -> None: diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index afc8344..9f746fc 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.6.1" + assert discolike.__version__ == "0.6.2" diff --git a/uv.lock b/uv.lock index 38b08de..50ba2dd 100644 --- a/uv.lock +++ b/uv.lock @@ -343,7 +343,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.6.1" +version = "0.6.2" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" },