discolike-cli 0.6.2__tar.gz → 0.6.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/PKG-INFO +6 -2
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/README.md +4 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/pyproject.toml +2 -2
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_help.py +1 -1
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/bulk.py +3 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/contacts.py +7 -0
- discolike_cli-0.6.3/src/discolike_cli/contacts_run.py +169 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/discover.py +5 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/email.py +14 -1
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/main.py +2 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_bulk_cli.py +13 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_company.py +3 -3
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_contacts_cli.py +1 -1
- discolike_cli-0.6.3/tests/test_contacts_run_cli.py +209 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_discover.py +18 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_sdk_parity.py +5 -2
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/.gitignore +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/LICENSE +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/__init__.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_inputs.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_loopback.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/_output.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/account.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/auth.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/company.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/discogen.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/enrich.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/match.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/prospecting.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/providers.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/py.typed +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/queries.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/src/discolike_cli/signup.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/conftest.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_account.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_auth.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_auth_oauth.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_discogen_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_email_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_enrich_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_entrypoint.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_file_inputs_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_help.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_inputs.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_match_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_output.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_prospecting_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_providers_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_queries_cli.py +0 -0
- {discolike_cli-0.6.2 → discolike_cli-0.6.3}/tests/test_signup_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: discolike-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Official CLI for the DiscoLike API
|
|
5
5
|
Project-URL: Homepage, https://www.discolike.com
|
|
6
6
|
Project-URL: Documentation, https://docs.discolike.com
|
|
@@ -25,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.15
|
|
|
25
25
|
Classifier: Topic :: Utilities
|
|
26
26
|
Classifier: Typing :: Typed
|
|
27
27
|
Requires-Python: >=3.11
|
|
28
|
-
Requires-Dist: discolike==0.6.
|
|
28
|
+
Requires-Dist: discolike==0.6.3
|
|
29
29
|
Requires-Dist: rich>=13.0
|
|
30
30
|
Requires-Dist: typer<0.28,>=0.26.1
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
@@ -69,6 +69,10 @@ discolike extract https://stripe.com/enterprise
|
|
|
69
69
|
|
|
70
70
|
Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
|
|
71
71
|
|
|
72
|
+
### Contacts runs
|
|
73
|
+
|
|
74
|
+
`discolike contacts run create --params-file run.json` starts a contacts run from a JSON request (`companies`, `people`, `relevance`, `per_company`, `max_contacts`, `auto_stop`, `find_missing`, `email_cap_usd`, `mode`). `get`, `list`, `continue`, `cancel` and `title-preview` mirror the SDK. `wait RUN_ID` blocks until the run finishes or pauses after a step; `--follow` continues each step for you and stops before `find_emails` unless the run was created with a cap or you pass `--email-cap-usd`. `contacts search`, `count` and `generate` still work but print a deprecation line on stderr after their output: their endpoints sunset on 2026-12-31. `email find --suggested-email` verifies an address you already have before looking for the right one.
|
|
75
|
+
|
|
72
76
|
### Volume pulls
|
|
73
77
|
|
|
74
78
|
`discolike bulk` walks past the 10,000-per-search ceiling and pulls contacts for a whole domain list into one CSV, with checkpoint and resume:
|
|
@@ -37,6 +37,10 @@ discolike extract https://stripe.com/enterprise
|
|
|
37
37
|
|
|
38
38
|
Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups.
|
|
39
39
|
|
|
40
|
+
### Contacts runs
|
|
41
|
+
|
|
42
|
+
`discolike contacts run create --params-file run.json` starts a contacts run from a JSON request (`companies`, `people`, `relevance`, `per_company`, `max_contacts`, `auto_stop`, `find_missing`, `email_cap_usd`, `mode`). `get`, `list`, `continue`, `cancel` and `title-preview` mirror the SDK. `wait RUN_ID` blocks until the run finishes or pauses after a step; `--follow` continues each step for you and stops before `find_emails` unless the run was created with a cap or you pass `--email-cap-usd`. `contacts search`, `count` and `generate` still work but print a deprecation line on stderr after their output: their endpoints sunset on 2026-12-31. `email find --suggested-email` verifies an address you already have before looking for the right one.
|
|
43
|
+
|
|
40
44
|
### Volume pulls
|
|
41
45
|
|
|
42
46
|
`discolike bulk` walks past the 10,000-per-search ceiling and pulls contacts for a whole domain list into one CSV, with checkpoint and resume:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "discolike-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.3"
|
|
8
8
|
description = "Official CLI for the DiscoLike API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -12,7 +12,7 @@ license-files = ["LICENSE"]
|
|
|
12
12
|
requires-python = ">=3.11"
|
|
13
13
|
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
|
|
14
14
|
dependencies = [
|
|
15
|
-
"discolike==0.6.
|
|
15
|
+
"discolike==0.6.3",
|
|
16
16
|
"typer>=0.26.1,<0.28",
|
|
17
17
|
"rich>=13.0",
|
|
18
18
|
]
|
|
@@ -185,7 +185,7 @@ Output (success, exit 0):
|
|
|
185
185
|
""",
|
|
186
186
|
"extract": """\
|
|
187
187
|
Output (success, exit 0):
|
|
188
|
-
{"text": <string
|
|
188
|
+
{"text": <string>}
|
|
189
189
|
Fetches the page live; use it to confirm a seed domain is the right company.
|
|
190
190
|
|
|
191
191
|
Common errors:
|
|
@@ -41,6 +41,7 @@ from discolike_cli.discover import DOMAINS_FILE_HELP
|
|
|
41
41
|
from discolike_cli.discover import PARAM_HELP
|
|
42
42
|
from discolike_cli.discover import PARAMS_FILE_HELP
|
|
43
43
|
from discolike_cli.discover import RELEVANCE_HELP
|
|
44
|
+
from discolike_cli.discover import VALID_MX_HELP
|
|
44
45
|
from discolike_cli.discover import VARIANCE_HELP
|
|
45
46
|
from discolike_cli.discover import _merge_params
|
|
46
47
|
|
|
@@ -159,6 +160,7 @@ def companies_command(
|
|
|
159
160
|
state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."),
|
|
160
161
|
employee_range: str | None = typer.Option(None, help="Employee count range filter."),
|
|
161
162
|
revenue_range: str | None = typer.Option(None, help="Revenue range filter."),
|
|
163
|
+
valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP),
|
|
162
164
|
relevance: str | None = typer.Option(None, help=RELEVANCE_HELP),
|
|
163
165
|
variance: str | None = typer.Option(None, help=VARIANCE_HELP),
|
|
164
166
|
exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP),
|
|
@@ -187,6 +189,7 @@ def companies_command(
|
|
|
187
189
|
state=state,
|
|
188
190
|
employee_range=employee_range,
|
|
189
191
|
revenue_range=revenue_range,
|
|
192
|
+
valid_mx=valid_mx,
|
|
190
193
|
relevance=relevance,
|
|
191
194
|
variance=variance,
|
|
192
195
|
),
|
|
@@ -59,6 +59,11 @@ MAX_COMPANIES_HELP = "Maximum number of enriched companies to return; cannot be
|
|
|
59
59
|
RESULTS_BY_COMPANY_HELP = "Maximum contacts per company domain (default 5; 0 removes the cap)."
|
|
60
60
|
INCLUDE_SEARCH_CONTACTS_HELP = "Include contacts from the search index (broader coverage)."
|
|
61
61
|
CONSENSUS_HELP = "Number of query vectors to combine for consensus search."
|
|
62
|
+
# Printed only after success, so a failed command's stderr stays the single JSON error object.
|
|
63
|
+
DEPRECATED_COMMAND_WARNING = (
|
|
64
|
+
"warning: `discolike contacts {command}` is deprecated and its endpoint sunsets on 2026-12-31; "
|
|
65
|
+
"use `discolike contacts run`."
|
|
66
|
+
)
|
|
62
67
|
|
|
63
68
|
|
|
64
69
|
def _email_validated_as_has_email(*, email_validated: bool | None, has_email: bool | None) -> bool | None:
|
|
@@ -174,6 +179,7 @@ def search_command(
|
|
|
174
179
|
consensus=consensus,
|
|
175
180
|
)
|
|
176
181
|
emit(get_client(ctx).contacts.search(build_request(ContactsSearchParams, kwargs)), fmt=fmt)
|
|
182
|
+
typer.echo(DEPRECATED_COMMAND_WARNING.format(command="search"), err=True)
|
|
177
183
|
|
|
178
184
|
|
|
179
185
|
@app.command("count")
|
|
@@ -510,3 +516,4 @@ def generate_command(
|
|
|
510
516
|
),
|
|
511
517
|
)
|
|
512
518
|
run_job(get_client(ctx).contacts.generate(request), wait=wait, timeout=timeout, fmt=fmt)
|
|
519
|
+
typer.echo(DEPRECATED_COMMAND_WARNING.format(command="generate"), err=True)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from discolike.requests import ContactsRunEventsParams
|
|
8
|
+
from discolike.requests import ContactsRunGetParams
|
|
9
|
+
from discolike.requests import ContactsRunListParams
|
|
10
|
+
from discolike.requests import ContactsRunRequest
|
|
11
|
+
from discolike.requests import ContinueRunRequest
|
|
12
|
+
from discolike.requests import TitlePreviewRequest
|
|
13
|
+
from discolike_cli._output import build_request
|
|
14
|
+
from discolike_cli._output import emit
|
|
15
|
+
from discolike_cli._output import handle_errors
|
|
16
|
+
from discolike_cli.discover import _merge_params
|
|
17
|
+
|
|
18
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS = 3600.0
|
|
19
|
+
|
|
20
|
+
FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
|
|
21
|
+
PARAMS_FILE_HELP = (
|
|
22
|
+
"JSON object with the run request: companies (source prompt, domains or lookalike), relevance, "
|
|
23
|
+
"people.fit, max_companies, per_company, email_cap_usd, exclusions and create_key. --param and flags override its values."
|
|
24
|
+
)
|
|
25
|
+
PARAM_HELP = "Extra key=value body field (repeatable)."
|
|
26
|
+
EMAIL_CAP_HELP = "Maximum USD to spend on validated delivery; omit for no cap."
|
|
27
|
+
GENERATION_HELP = "Generation of the companies run you are continuing; fetched when omitted."
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(help="Find and validate contacts automatically. Poll company progress and delivery totals.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("create")
|
|
33
|
+
@handle_errors
|
|
34
|
+
def create_command(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
params_file: pathlib.Path | None = typer.Option(None, help=PARAMS_FILE_HELP),
|
|
37
|
+
param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP),
|
|
38
|
+
email_cap_usd: float | None = typer.Option(None, "--email-cap-usd", help=EMAIL_CAP_HELP),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Start a contacts run from a JSON request."""
|
|
41
|
+
from discolike_cli.main import get_client
|
|
42
|
+
|
|
43
|
+
request = build_request(ContactsRunRequest, _merge_params(param, params_file, email_cap_usd=email_cap_usd))
|
|
44
|
+
emit(get_client(ctx).contacts_run.create(request))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("list")
|
|
48
|
+
@handle_errors
|
|
49
|
+
def list_command(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
before: str | None = typer.Option(
|
|
52
|
+
None, help="Only runs created before this time, ISO 8601 with offset, e.g. 2026-10-30T12:00:00Z."
|
|
53
|
+
),
|
|
54
|
+
limit: int | None = typer.Option(None, help="Runs per page (default 20, maximum 100)."),
|
|
55
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""List your runs, newest first."""
|
|
58
|
+
from discolike_cli.main import get_client
|
|
59
|
+
|
|
60
|
+
params = build_request(ContactsRunListParams, _merge_params(None, before=before, limit=limit))
|
|
61
|
+
emit(get_client(ctx).contacts_run.list(params), fmt=fmt)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command("get")
|
|
65
|
+
@handle_errors
|
|
66
|
+
def get_command(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
run_id: str = typer.Argument(..., help="Run ID."),
|
|
69
|
+
rows_offset: int | None = typer.Option(None, help="Company domains to skip for contacts runs."),
|
|
70
|
+
rows_limit: int | None = typer.Option(
|
|
71
|
+
None, help="Company domains per page for contacts runs (default 100, maximum 1000)."
|
|
72
|
+
),
|
|
73
|
+
all_rows: bool = typer.Option(False, "--all-rows", help="Print every row of the run as a list instead."),
|
|
74
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Show a run with a page of its rows."""
|
|
77
|
+
from discolike_cli.main import get_client
|
|
78
|
+
|
|
79
|
+
client = get_client(ctx)
|
|
80
|
+
if all_rows:
|
|
81
|
+
emit(client.contacts_run.rows(run_id), fmt=fmt)
|
|
82
|
+
return
|
|
83
|
+
params = build_request(ContactsRunGetParams, _merge_params(None, rows_offset=rows_offset, rows_limit=rows_limit))
|
|
84
|
+
emit(client.contacts_run.get(run_id, params))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command("continue")
|
|
88
|
+
@handle_errors
|
|
89
|
+
def continue_command(
|
|
90
|
+
ctx: typer.Context,
|
|
91
|
+
run_id: str = typer.Argument(..., help="Run ID."),
|
|
92
|
+
generation: int | None = typer.Option(None, help=GENERATION_HELP),
|
|
93
|
+
redo: bool | None = typer.Option(None, "--redo/--no-redo", help="Run the current step again."),
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Advance or redo a companies run. Contacts runs proceed automatically (409 here)."""
|
|
96
|
+
from discolike_cli.main import get_client
|
|
97
|
+
|
|
98
|
+
client = get_client(ctx)
|
|
99
|
+
request = build_request(
|
|
100
|
+
ContinueRunRequest,
|
|
101
|
+
_merge_params(
|
|
102
|
+
None,
|
|
103
|
+
generation=client.contacts_run.get(run_id).generation if generation is None else generation,
|
|
104
|
+
redo=redo,
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
emit(client.contacts_run.continue_(run_id, request))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("cancel")
|
|
111
|
+
@handle_errors
|
|
112
|
+
def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID.")) -> None:
|
|
113
|
+
"""Cancel a run; charges already incurred stay."""
|
|
114
|
+
from discolike_cli.main import get_client
|
|
115
|
+
|
|
116
|
+
emit(get_client(ctx).contacts_run.cancel(run_id))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command("wait")
|
|
120
|
+
@handle_errors
|
|
121
|
+
def wait_command(
|
|
122
|
+
ctx: typer.Context,
|
|
123
|
+
run_id: str = typer.Argument(..., help="Run ID."),
|
|
124
|
+
timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help="Max seconds to wait."),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Block until done, failed or cancelled. A timeout stops local polling only."""
|
|
127
|
+
from discolike_cli.main import get_client
|
|
128
|
+
|
|
129
|
+
emit(get_client(ctx).contacts_run.wait(run_id, max_wait=timeout))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command("events")
|
|
133
|
+
@handle_errors
|
|
134
|
+
def events_command(
|
|
135
|
+
ctx: typer.Context,
|
|
136
|
+
run_id: str = typer.Argument(..., help="Run ID."),
|
|
137
|
+
after: int | None = typer.Option(None, help="Return events after this cursor."),
|
|
138
|
+
limit: int | None = typer.Option(None, help="Events per page (default 100, maximum 500)."),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Read company progress events and the next cursor."""
|
|
141
|
+
from discolike_cli.main import get_client
|
|
142
|
+
|
|
143
|
+
params = build_request(ContactsRunEventsParams, _merge_params(None, after=after, limit=limit))
|
|
144
|
+
emit(get_client(ctx).contacts_run.events(run_id, params))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("title-preview")
|
|
148
|
+
@handle_errors
|
|
149
|
+
def title_preview_command(
|
|
150
|
+
ctx: typer.Context,
|
|
151
|
+
title: list[str] = typer.Option(..., "--title", help="Job title to preview (repeatable)."),
|
|
152
|
+
exclude_title: list[str] | None = typer.Option(
|
|
153
|
+
None, "--exclude-title", help="Job title to leave out (repeatable)."
|
|
154
|
+
),
|
|
155
|
+
match: str = typer.Option("similar", help="similar also accepts close variants of a title; exact does not."),
|
|
156
|
+
relevance: str | None = typer.Option(None, help="max_relevance, balanced or max_coverage."),
|
|
157
|
+
auto_stop: bool | None = typer.Option(None, "--auto-stop/--no-auto-stop", help="Apply the relevance threshold."),
|
|
158
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Preview the titles a people spec matches, with role scores and contact counts."""
|
|
161
|
+
from discolike_cli.main import get_client
|
|
162
|
+
|
|
163
|
+
request = build_request(
|
|
164
|
+
TitlePreviewRequest,
|
|
165
|
+
_merge_params(
|
|
166
|
+
None, titles=title, exclude_titles=exclude_title, match=match, relevance=relevance, auto_stop=auto_stop
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
emit(get_client(ctx).contacts_run.title_preview(request).titles, fmt=fmt)
|
|
@@ -42,6 +42,7 @@ NEGATE_SOCIAL_HELP = "Exclude companies with this social profile (repeatable); s
|
|
|
42
42
|
LANGUAGE_HELP = "Filter by site language as an ISO 639-1 code, e.g. en, de, pb, zt (repeatable)."
|
|
43
43
|
NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language."
|
|
44
44
|
EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)."
|
|
45
|
+
VALID_MX_HELP = "Exclude companies with no mail server (off by default)."
|
|
45
46
|
COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)."
|
|
46
47
|
RELEVANCE_HELP = (
|
|
47
48
|
"Where the list stops, by estimated ICP fit: MAX_RELEVANCE (tightest), BALANCED, MAX_COVERAGE (longest). "
|
|
@@ -114,6 +115,7 @@ def discover_command(
|
|
|
114
115
|
consensus: int | None = typer.Option(
|
|
115
116
|
None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches."
|
|
116
117
|
),
|
|
118
|
+
valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP),
|
|
117
119
|
redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP),
|
|
118
120
|
exclude_leadgen: bool | None = typer.Option(
|
|
119
121
|
None, "--exclude-leadgen/--no-exclude-leadgen", help=EXCLUDE_LEADGEN_HELP
|
|
@@ -193,6 +195,7 @@ def discover_command(
|
|
|
193
195
|
relevance=relevance,
|
|
194
196
|
variance=variance,
|
|
195
197
|
consensus=consensus,
|
|
198
|
+
valid_mx=valid_mx,
|
|
196
199
|
redirect=redirect,
|
|
197
200
|
exclude_leadgen=exclude_leadgen,
|
|
198
201
|
retrieval=retrieval,
|
|
@@ -245,6 +248,7 @@ def count_command(
|
|
|
245
248
|
negate_tech_stack: list[str] | None = typer.Option(None, help="Negate the --tech-stack filter (repeatable)."),
|
|
246
249
|
min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."),
|
|
247
250
|
max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."),
|
|
251
|
+
valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP),
|
|
248
252
|
redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP),
|
|
249
253
|
exclude_leadgen: bool | None = typer.Option(
|
|
250
254
|
None, "--exclude-leadgen/--no-exclude-leadgen", help=COUNT_EXCLUDE_LEADGEN_HELP
|
|
@@ -291,6 +295,7 @@ def count_command(
|
|
|
291
295
|
negate_tech_stack=negate_tech_stack,
|
|
292
296
|
min_digital_footprint=min_digital_footprint,
|
|
293
297
|
max_digital_footprint=max_digital_footprint,
|
|
298
|
+
valid_mx=valid_mx,
|
|
294
299
|
redirect=redirect,
|
|
295
300
|
exclude_leadgen=exclude_leadgen,
|
|
296
301
|
),
|
|
@@ -86,6 +86,12 @@ def find_command(
|
|
|
86
86
|
known_pattern: str | None = typer.Option(
|
|
87
87
|
None, "--known-pattern", help="Known email local-part pattern for this domain, e.g. first.last."
|
|
88
88
|
),
|
|
89
|
+
suggested_email: str | None = typer.Option(
|
|
90
|
+
None,
|
|
91
|
+
"--suggested-email",
|
|
92
|
+
help="Address you think is right: verified first, and the right one recovered if it is not proven. "
|
|
93
|
+
"Must be at DOMAIN or the company's mail domain.",
|
|
94
|
+
),
|
|
89
95
|
wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP),
|
|
90
96
|
timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
|
|
91
97
|
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
@@ -95,7 +101,14 @@ def find_command(
|
|
|
95
101
|
|
|
96
102
|
request = build_request(
|
|
97
103
|
FindEmailRequest,
|
|
98
|
-
_merge_params(
|
|
104
|
+
_merge_params(
|
|
105
|
+
None,
|
|
106
|
+
first_name=first_name,
|
|
107
|
+
last_name=last_name,
|
|
108
|
+
domain=domain,
|
|
109
|
+
known_pattern=known_pattern,
|
|
110
|
+
suggested_email=suggested_email,
|
|
111
|
+
),
|
|
99
112
|
)
|
|
100
113
|
job = get_client(ctx).email.find(request)
|
|
101
114
|
if not wait:
|
|
@@ -18,6 +18,7 @@ from discolike_cli import auth
|
|
|
18
18
|
from discolike_cli import bulk
|
|
19
19
|
from discolike_cli import company
|
|
20
20
|
from discolike_cli import contacts
|
|
21
|
+
from discolike_cli import contacts_run
|
|
21
22
|
from discolike_cli import discogen
|
|
22
23
|
from discolike_cli import discover
|
|
23
24
|
from discolike_cli import email
|
|
@@ -83,6 +84,7 @@ app.add_typer(auth.app, name="auth")
|
|
|
83
84
|
app.add_typer(bulk.app, name="bulk")
|
|
84
85
|
app.add_typer(company.app, name="company")
|
|
85
86
|
app.add_typer(contacts.app, name="contacts")
|
|
87
|
+
contacts.app.add_typer(contacts_run.app, name="run")
|
|
86
88
|
app.add_typer(discogen.app, name="discogen")
|
|
87
89
|
app.add_typer(email.app, name="email")
|
|
88
90
|
app.add_typer(queries.app, name="queries")
|
|
@@ -661,3 +661,16 @@ def test_bulk_rejects_non_positive_rate_limit(
|
|
|
661
661
|
result = runner.invoke(app, ["bulk", *command, *extra, "--out", str(tmp_path / "c.csv"), "--rate-limit", "0"])
|
|
662
662
|
assert result.exit_code == 2, result.output
|
|
663
663
|
assert recorder.requests == []
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")])
|
|
667
|
+
def test_bulk_companies_valid_mx(
|
|
668
|
+
install_build_client: Callable[[Handler], None], tmp_path: Path, flags: list[str], expected: str | None
|
|
669
|
+
) -> None:
|
|
670
|
+
recorder = Recorder(discover=[_companies("a", 1)])
|
|
671
|
+
install_build_client(recorder)
|
|
672
|
+
result = runner.invoke(
|
|
673
|
+
app, ["bulk", "companies", "--out", str(tmp_path / "companies.csv"), "--max-companies", "1", *flags]
|
|
674
|
+
)
|
|
675
|
+
assert result.exit_code == 0, result.output
|
|
676
|
+
assert recorder.params("/v1/discover")[0].get("valid_mx") == expected
|
|
@@ -135,7 +135,7 @@ def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[
|
|
|
135
135
|
|
|
136
136
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
137
137
|
captured["request"] = request
|
|
138
|
-
return httpx2.Response(200, json={"text": "hello"
|
|
138
|
+
return httpx2.Response(200, json={"text": "hello"})
|
|
139
139
|
|
|
140
140
|
install_build_client(handler)
|
|
141
141
|
result = runner.invoke(app, ["extract", "https://acme.com/about"])
|
|
@@ -143,7 +143,7 @@ def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[
|
|
|
143
143
|
request = captured["request"]
|
|
144
144
|
assert request.url.path == "/v1/extract"
|
|
145
145
|
assert request.url.params.get("url") == "https://acme.com/about"
|
|
146
|
-
assert json.loads(result.stdout) == {"text": "hello"
|
|
146
|
+
assert json.loads(result.stdout) == {"text": "hello"}
|
|
147
147
|
|
|
148
148
|
|
|
149
149
|
def test_extract_domain_option_replaces_url(install_build_client: Callable[[Handler], None]) -> None:
|
|
@@ -151,7 +151,7 @@ def test_extract_domain_option_replaces_url(install_build_client: Callable[[Hand
|
|
|
151
151
|
|
|
152
152
|
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
153
153
|
captured["request"] = request
|
|
154
|
-
return httpx2.Response(200, json={"text": "hello"
|
|
154
|
+
return httpx2.Response(200, json={"text": "hello"})
|
|
155
155
|
|
|
156
156
|
install_build_client(handler)
|
|
157
157
|
result = runner.invoke(app, ["extract", "--domain", "acme.com"])
|
|
@@ -532,7 +532,7 @@ def test_contacts_without_email_validated_does_not_warn(install_build_client: Ca
|
|
|
532
532
|
install_build_client(lambda request: httpx2.Response(200, json=[]))
|
|
533
533
|
result = runner.invoke(app, ["contacts", "search", "--name", "Jane"])
|
|
534
534
|
assert result.exit_code == 0, result.output
|
|
535
|
-
assert "
|
|
535
|
+
assert "email-validated" not in result.stderr
|
|
536
536
|
|
|
537
537
|
|
|
538
538
|
def test_contacts_generate_forwards_full_and_partial_domains(install_build_client: Callable[[Handler], None]) -> None:
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx2
|
|
10
|
+
import pytest
|
|
11
|
+
from typer.testing import CliRunner
|
|
12
|
+
|
|
13
|
+
from discolike_cli.main import app
|
|
14
|
+
from discolike_testkit import Handler
|
|
15
|
+
|
|
16
|
+
runner = CliRunner()
|
|
17
|
+
|
|
18
|
+
RUN_ID = "00000000-0000-0000-0000-0000000000bb"
|
|
19
|
+
RUN_PATH = f"/v1/contacts/run/{RUN_ID}"
|
|
20
|
+
TIMESTAMP = "2026-10-30T12:00:00Z"
|
|
21
|
+
STEPS = ("find_contacts", "find_missing", "review", "find_emails")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
26
|
+
monkeypatch.setattr(time, "sleep", lambda seconds: None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_payload(*, status: str = "waiting", step: int = 0, generation: int = 1) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"id": RUN_ID,
|
|
32
|
+
"kind": "contacts",
|
|
33
|
+
"status": status,
|
|
34
|
+
"current_step": STEPS[step],
|
|
35
|
+
"generation": generation,
|
|
36
|
+
"steps": {STEPS[step]: {"status": "done"}},
|
|
37
|
+
"created_at": TIMESTAMP,
|
|
38
|
+
"updated_at": TIMESTAMP,
|
|
39
|
+
"expires_at": TIMESTAMP,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_run_create_posts_params_file(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None:
|
|
44
|
+
bodies: list[dict[str, Any]] = []
|
|
45
|
+
|
|
46
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
47
|
+
bodies.append(json.loads(request.content))
|
|
48
|
+
return httpx2.Response(202, json=run_payload(status="running"))
|
|
49
|
+
|
|
50
|
+
install_build_client(handler)
|
|
51
|
+
params_file = tmp_path / "run.json"
|
|
52
|
+
params_file.write_text(
|
|
53
|
+
json.dumps({"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"fit": "Owners"}})
|
|
54
|
+
)
|
|
55
|
+
result = runner.invoke(
|
|
56
|
+
app, ["contacts", "run", "create", "--params-file", str(params_file), "--email-cap-usd", "20"]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert result.exit_code == 0, result.output
|
|
60
|
+
assert bodies == [
|
|
61
|
+
{
|
|
62
|
+
"companies": {"source": "prompt", "icp_text": "dental clinics"},
|
|
63
|
+
"people": {"fit": "Owners"},
|
|
64
|
+
"email_cap_usd": "20.0",
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
assert json.loads(result.stdout)["status"] == "running"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_run_continue_fetches_generation_when_omitted(install_build_client: Callable[[Handler], None]) -> None:
|
|
71
|
+
seen: list[tuple[str, str, Any]] = []
|
|
72
|
+
|
|
73
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
74
|
+
body = json.loads(request.content) if request.content else None
|
|
75
|
+
seen.append((request.method, request.url.path, body))
|
|
76
|
+
return httpx2.Response(200, json=run_payload(generation=4))
|
|
77
|
+
|
|
78
|
+
install_build_client(handler)
|
|
79
|
+
result = runner.invoke(app, ["contacts", "run", "continue", RUN_ID, "--redo"])
|
|
80
|
+
|
|
81
|
+
assert result.exit_code == 0, result.output
|
|
82
|
+
assert seen == [
|
|
83
|
+
("GET", RUN_PATH, None),
|
|
84
|
+
("POST", f"{RUN_PATH}/continue", {"generation": 4, "redo": True}),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_run_wait_polls_automatically(install_build_client: Callable[[Handler], None]) -> None:
|
|
89
|
+
statuses = iter(["ready", "running", "done"])
|
|
90
|
+
|
|
91
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
92
|
+
assert request.method == "GET"
|
|
93
|
+
return httpx2.Response(200, json=run_payload(status=next(statuses)))
|
|
94
|
+
|
|
95
|
+
install_build_client(handler)
|
|
96
|
+
result = runner.invoke(app, ["contacts", "run", "wait", RUN_ID])
|
|
97
|
+
assert result.exit_code == 0, result.output
|
|
98
|
+
assert json.loads(result.stdout)["status"] == "done"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_run_events(install_build_client: Callable[[Handler], None]) -> None:
|
|
102
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
103
|
+
assert request.url.path == RUN_PATH + "/events"
|
|
104
|
+
assert dict(request.url.params) == {"after": "4", "limit": "10"}
|
|
105
|
+
return httpx2.Response(200, json={"events": [], "next_cursor": 5})
|
|
106
|
+
|
|
107
|
+
install_build_client(handler)
|
|
108
|
+
result = runner.invoke(app, ["contacts", "run", "events", RUN_ID, "--after", "4", "--limit", "10"])
|
|
109
|
+
assert result.exit_code == 0, result.output
|
|
110
|
+
assert json.loads(result.stdout)["next_cursor"] == 5
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.mark.parametrize("flag", ["--follow", "--email-cap-usd"])
|
|
114
|
+
def test_removed_wait_flags(flag: str) -> None:
|
|
115
|
+
assert runner.invoke(app, ["contacts", "run", "wait", RUN_ID, flag]).exit_code == 2
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_run_cancel_and_get(install_build_client: Callable[[Handler], None]) -> None:
|
|
119
|
+
seen: list[tuple[str, str, str]] = []
|
|
120
|
+
|
|
121
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
122
|
+
seen.append((request.method, request.url.path, request.url.query.decode()))
|
|
123
|
+
return httpx2.Response(200, json=run_payload(status="cancelled"))
|
|
124
|
+
|
|
125
|
+
install_build_client(handler)
|
|
126
|
+
assert runner.invoke(app, ["contacts", "run", "cancel", RUN_ID]).exit_code == 0
|
|
127
|
+
assert runner.invoke(app, ["contacts", "run", "get", RUN_ID, "--rows-limit", "10"]).exit_code == 0
|
|
128
|
+
assert seen == [("POST", f"{RUN_PATH}/cancel", ""), ("GET", RUN_PATH, "rows_limit=10")]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_run_list_sends_before(install_build_client: Callable[[Handler], None]) -> None:
|
|
132
|
+
queries: list[httpx2.QueryParams] = []
|
|
133
|
+
|
|
134
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
135
|
+
queries.append(request.url.params)
|
|
136
|
+
return httpx2.Response(200, json=[run_payload()])
|
|
137
|
+
|
|
138
|
+
install_build_client(handler)
|
|
139
|
+
result = runner.invoke(app, ["contacts", "run", "list", "--before", "2026-10-30T00:00:00+00:00", "--limit", "5"])
|
|
140
|
+
assert result.exit_code == 0, result.output
|
|
141
|
+
assert queries[0]["before"] == "2026-10-30T00:00:00Z"
|
|
142
|
+
assert queries[0]["limit"] == "5"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_run_title_preview(install_build_client: Callable[[Handler], None]) -> None:
|
|
146
|
+
bodies: list[dict[str, Any]] = []
|
|
147
|
+
|
|
148
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
149
|
+
bodies.append(json.loads(request.content))
|
|
150
|
+
return httpx2.Response(200, json={"titles": [{"title": "Owner", "role": 1.0, "count": 3}]})
|
|
151
|
+
|
|
152
|
+
install_build_client(handler)
|
|
153
|
+
result = runner.invoke(app, ["contacts", "run", "title-preview", "--title", "Owner", "--format", "json"])
|
|
154
|
+
assert result.exit_code == 0, result.output
|
|
155
|
+
assert bodies == [{"titles": ["Owner"], "match": "similar"}]
|
|
156
|
+
assert json.loads(result.stdout) == [{"title": "Owner", "role": 1.0, "count": 3}]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_old_contacts_commands_print_deprecation(install_build_client: Callable[[Handler], None]) -> None:
|
|
160
|
+
install_build_client(lambda request: httpx2.Response(200, json=[]))
|
|
161
|
+
result = runner.invoke(app, ["contacts", "search", "--name", "Jane"])
|
|
162
|
+
assert result.exit_code == 0, result.output
|
|
163
|
+
assert "`discolike contacts search` is deprecated" in result.stderr
|
|
164
|
+
assert "use `discolike contacts run`" in result.stderr
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_contacts_discover_prints_no_deprecation(install_build_client: Callable[[Handler], None]) -> None:
|
|
168
|
+
install_build_client(lambda request: httpx2.Response(200, json={"results": {}}))
|
|
169
|
+
result = runner.invoke(app, ["contacts", "discover", "--domain", "acme.com"])
|
|
170
|
+
assert result.exit_code == 0, result.output
|
|
171
|
+
assert "deprecated" not in result.stderr
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_email_find_sends_suggested_email(install_build_client: Callable[[Handler], None]) -> None:
|
|
175
|
+
bodies: list[dict[str, Any]] = []
|
|
176
|
+
|
|
177
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
178
|
+
bodies.append(json.loads(request.content))
|
|
179
|
+
return httpx2.Response(202, json={"job_id": "j-1", "status": "queued"})
|
|
180
|
+
|
|
181
|
+
install_build_client(handler)
|
|
182
|
+
result = runner.invoke(app, ["email", "find", "Grace", "Hopper", "navy.mil", "--suggested-email", "grace@navy.mil"])
|
|
183
|
+
assert result.exit_code == 0, result.output
|
|
184
|
+
assert bodies == [
|
|
185
|
+
{"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil", "suggested_email": "grace@navy.mil"}
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_run_insufficient_balance(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None:
|
|
190
|
+
install_build_client(
|
|
191
|
+
lambda _: httpx2.Response(
|
|
192
|
+
403,
|
|
193
|
+
json={
|
|
194
|
+
"detail": {
|
|
195
|
+
"code": "INSUFFICIENT_BALANCE",
|
|
196
|
+
"message": "Top up to continue",
|
|
197
|
+
"remaining_usd": 1,
|
|
198
|
+
"max_affordable_records": 2,
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
params = tmp_path / "run.json"
|
|
204
|
+
params.write_text(json.dumps({"companies": {"source": "domains", "domains": ["acme.com"]}}))
|
|
205
|
+
result = runner.invoke(app, ["contacts", "run", "create", "--params-file", str(params)])
|
|
206
|
+
assert result.exit_code == 3
|
|
207
|
+
error = json.loads(result.stderr)
|
|
208
|
+
assert error["code"] == "insufficient_balance"
|
|
209
|
+
assert error["max_affordable_records"] == 2
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
|
|
6
6
|
import httpx2
|
|
7
|
+
import pytest
|
|
7
8
|
from typer.testing import CliRunner
|
|
8
9
|
|
|
9
10
|
from discolike_cli.main import app
|
|
@@ -411,3 +412,20 @@ def test_discover_unauthorized_exits_3(install_build_client: Callable[[Handler],
|
|
|
411
412
|
assert result.exit_code == 3
|
|
412
413
|
payload = json.loads(result.stderr)
|
|
413
414
|
assert payload["error"] == "AuthenticationError"
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@pytest.mark.parametrize("command", ["discover", "count"])
|
|
418
|
+
@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")])
|
|
419
|
+
def test_valid_mx_flag(
|
|
420
|
+
install_build_client: Callable[[Handler], None], command: str, flags: list[str], expected: str | None
|
|
421
|
+
) -> None:
|
|
422
|
+
seen: list[httpx2.QueryParams] = []
|
|
423
|
+
|
|
424
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
425
|
+
seen.append(request.url.params)
|
|
426
|
+
return _count_ok(request) if command == "count" else _discover_ok(request)
|
|
427
|
+
|
|
428
|
+
install_build_client(handler)
|
|
429
|
+
result = runner.invoke(app, [command, *flags])
|
|
430
|
+
assert result.exit_code == 0, result.output
|
|
431
|
+
assert seen[0].get("valid_mx") == expected
|
|
@@ -29,7 +29,10 @@ DELIBERATELY_OMITTED: dict[str, frozenset[str]] = {
|
|
|
29
29
|
}
|
|
30
30
|
# ``discolike bulk`` takes the full vocabulary through --params-file / --param and manages the paging
|
|
31
31
|
# fields itself; its flags are the handful a volume run needs, not one per SDK field.
|
|
32
|
-
|
|
32
|
+
# ``contacts run create`` takes its nested companies/people request the same way.
|
|
33
|
+
PARAMS_FILE_SITES = frozenset(
|
|
34
|
+
{"bulk.companies_command", "bulk.estimate_command", "bulk.contacts_command", "contacts_run.create_command"}
|
|
35
|
+
)
|
|
33
36
|
DICT_ONLY_FIELDS: dict[str, frozenset[str]] = {
|
|
34
37
|
"ContactGenerateRequest": frozenset({"initial_contact_counts"}),
|
|
35
38
|
"DiscoGenProcessRequest": frozenset({"previous_discogen_data"}),
|
|
@@ -97,7 +100,7 @@ def test_cli_forwards_every_sdk_field(site: str, model_name: str, forwarded: set
|
|
|
97
100
|
|
|
98
101
|
|
|
99
102
|
def test_every_request_model_has_a_cli_command() -> None:
|
|
100
|
-
used = {model_name for _, model_name, _ in
|
|
103
|
+
used = {model_name for _, model_name, _ in _build_request_sites()}
|
|
101
104
|
declared = {
|
|
102
105
|
name
|
|
103
106
|
for name in dir(requests_module)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|