discolike-cli 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- discolike_cli-0.2.0/LICENSE +21 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/PKG-INFO +12 -4
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/README.md +1 -1
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/pyproject.toml +10 -2
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/auth.py +32 -14
- discolike_cli-0.2.0/src/discolike_cli/email.py +166 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/main.py +3 -1
- discolike_cli-0.2.0/src/discolike_cli/py.typed +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/queries.py +52 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/conftest.py +15 -3
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_account.py +3 -3
- discolike_cli-0.2.0/tests/test_auth.py +210 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_company.py +20 -20
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_contacts_cli.py +35 -35
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_discogen_cli.py +25 -25
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_discover.py +17 -17
- discolike_cli-0.2.0/tests/test_email_cli.py +283 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_enrich_cli.py +31 -31
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_match_cli.py +29 -29
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_providers_cli.py +22 -22
- discolike_cli-0.2.0/tests/test_queries_cli.py +180 -0
- discolike_cli-0.1.1/tests/test_auth.py +0 -120
- discolike_cli-0.1.1/tests/test_queries_cli.py +0 -96
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/.gitignore +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/__init__.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/_output.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/account.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/company.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/contacts.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/discogen.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/discover.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/enrich.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/match.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/providers.py +0 -0
- {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_output.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DiscoLike Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,30 +1,38 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: discolike-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
7
7
|
Project-URL: Repository, https://github.com/Discolike/discolike-python
|
|
8
|
+
Project-URL: Changelog, https://github.com/Discolike/discolike-python/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/Discolike/discolike-python/issues
|
|
8
10
|
Author-email: DiscoLike <support@discolike.com>
|
|
9
11
|
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
10
13
|
Keywords: business-data,cli,discolike,enrichment
|
|
11
14
|
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
12
16
|
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
13
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
20
|
Classifier: Programming Language :: Python :: 3.10
|
|
15
21
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
23
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
24
|
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Classifier: Typing :: Typed
|
|
19
27
|
Requires-Python: >=3.10
|
|
20
|
-
Requires-Dist: discolike
|
|
28
|
+
Requires-Dist: discolike==0.2.0
|
|
21
29
|
Requires-Dist: rich>=13.0
|
|
22
30
|
Requires-Dist: typer>=0.12
|
|
23
31
|
Description-Content-Type: text/markdown
|
|
24
32
|
|
|
25
33
|
# discolike-cli
|
|
26
34
|
|
|
27
|
-
Official CLI for the [DiscoLike API](https://
|
|
35
|
+
Official CLI for the [DiscoLike API](https://discolike.com) — the `discolike` command from your terminal. Built on the [`discolike`](https://pypi.org/project/discolike/) SDK.
|
|
28
36
|
|
|
29
37
|
## Installation
|
|
30
38
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# discolike-cli
|
|
2
2
|
|
|
3
|
-
Official CLI for the [DiscoLike API](https://
|
|
3
|
+
Official CLI for the [DiscoLike API](https://discolike.com) — the `discolike` command from your terminal. Built on the [`discolike`](https://pypi.org/project/discolike/) SDK.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -4,14 +4,15 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "discolike-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Official CLI for the DiscoLike API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
11
12
|
requires-python = ">=3.10"
|
|
12
13
|
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
|
|
13
14
|
dependencies = [
|
|
14
|
-
"discolike
|
|
15
|
+
"discolike==0.2.0",
|
|
15
16
|
"typer>=0.12",
|
|
16
17
|
"rich>=13.0",
|
|
17
18
|
]
|
|
@@ -25,6 +26,11 @@ classifiers = [
|
|
|
25
26
|
"Programming Language :: Python :: 3.12",
|
|
26
27
|
"Programming Language :: Python :: 3.13",
|
|
27
28
|
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Environment :: Console",
|
|
32
|
+
"Topic :: Utilities",
|
|
33
|
+
"Typing :: Typed",
|
|
28
34
|
]
|
|
29
35
|
|
|
30
36
|
[project.scripts]
|
|
@@ -34,6 +40,8 @@ discolike = "discolike_cli.main:app"
|
|
|
34
40
|
Homepage = "https://www.discolike.com"
|
|
35
41
|
Documentation = "https://docs.discolike.com"
|
|
36
42
|
Repository = "https://github.com/Discolike/discolike-python"
|
|
43
|
+
Changelog = "https://github.com/Discolike/discolike-python/blob/main/CHANGELOG.md"
|
|
44
|
+
Issues = "https://github.com/Discolike/discolike-python/issues"
|
|
37
45
|
|
|
38
46
|
[tool.uv.sources]
|
|
39
47
|
discolike-testkit = { workspace = true }
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import os
|
|
5
4
|
import sys
|
|
5
|
+
from typing import Any
|
|
6
6
|
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
|
-
from discolike._config import ENV_API_KEY
|
|
10
9
|
from discolike._config import KEYS_URL
|
|
11
10
|
from discolike._config import delete_config
|
|
12
11
|
from discolike._config import load_config
|
|
@@ -19,39 +18,58 @@ app = typer.Typer(help="Manage API credentials: log in, check key status, log ou
|
|
|
19
18
|
|
|
20
19
|
MASKED_VISIBLE_CHARS = 4
|
|
21
20
|
|
|
21
|
+
SOURCE_OPTION = "option"
|
|
22
|
+
SOURCE_ENV = "env"
|
|
23
|
+
SOURCE_CONFIG = "config"
|
|
24
|
+
|
|
22
25
|
|
|
23
26
|
def _mask(key: str) -> str:
|
|
24
27
|
return "…" + key[-MASKED_VISIBLE_CHARS:]
|
|
25
28
|
|
|
26
29
|
|
|
30
|
+
def _global_key_source(ctx: typer.Context) -> str:
|
|
31
|
+
# typer vendors click without re-exporting ParameterSource, so match on the enum member name.
|
|
32
|
+
source = ctx.find_root().get_parameter_source("api_key")
|
|
33
|
+
return SOURCE_ENV if source is not None and source.name == "ENVIRONMENT" else SOURCE_OPTION
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _verify(ctx: typer.Context, *, api_key: str) -> None:
|
|
37
|
+
from discolike_cli.main import build_client
|
|
38
|
+
|
|
39
|
+
kwargs: dict[str, Any] = {"api_key": api_key}
|
|
40
|
+
base_url = ctx.obj.get("base_url")
|
|
41
|
+
if base_url is not None:
|
|
42
|
+
kwargs["base_url"] = base_url
|
|
43
|
+
build_client(**kwargs).account.usage()
|
|
44
|
+
|
|
45
|
+
|
|
27
46
|
@app.command()
|
|
28
47
|
@handle_errors
|
|
29
48
|
def login(
|
|
49
|
+
ctx: typer.Context,
|
|
30
50
|
api_key: str | None = typer.Option(None, help=f"API key. Create one at {KEYS_URL}. Prompted for if omitted."),
|
|
31
51
|
) -> None:
|
|
32
52
|
"""Verify an API key and save it to the local config file."""
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
key = api_key or typer.prompt("API key", hide_input=True)
|
|
36
|
-
|
|
53
|
+
# An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may.
|
|
54
|
+
passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None
|
|
55
|
+
key = api_key or passed_globally or typer.prompt("API key", hide_input=True)
|
|
56
|
+
_verify(ctx, api_key=key)
|
|
37
57
|
save_config({"auth_method": "api_key", "api_key": key})
|
|
38
58
|
print(json.dumps({"logged_in": True, "source": "api_key"}), file=sys.stderr)
|
|
39
59
|
|
|
40
60
|
|
|
41
61
|
@app.command()
|
|
42
62
|
@handle_errors
|
|
43
|
-
def status() -> None:
|
|
44
|
-
"""Show which API key is in use (env or config) and verify it against the API."""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
key = os.environ.get(ENV_API_KEY)
|
|
48
|
-
source = "env"
|
|
63
|
+
def status(ctx: typer.Context) -> None:
|
|
64
|
+
"""Show which API key is in use (option, env, or config) and verify it against the API."""
|
|
65
|
+
key = ctx.obj.get("api_key")
|
|
66
|
+
source = _global_key_source(ctx)
|
|
49
67
|
if not key:
|
|
50
68
|
key = load_config().get("api_key")
|
|
51
|
-
source =
|
|
69
|
+
source = SOURCE_CONFIG
|
|
52
70
|
if not key:
|
|
53
71
|
resolve_api_key(None)
|
|
54
|
-
|
|
72
|
+
_verify(ctx, api_key=str(key))
|
|
55
73
|
emit({"source": source, "api_key": _mask(str(key)), "valid": True})
|
|
56
74
|
|
|
57
75
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from discolike._email import EmailBatch
|
|
10
|
+
from discolike._email import EmailBatchResults
|
|
11
|
+
from discolike._email import EmailJobResult
|
|
12
|
+
from discolike._exceptions import JobTimeoutError
|
|
13
|
+
from discolike_cli._output import emit
|
|
14
|
+
from discolike_cli._output import handle_errors
|
|
15
|
+
|
|
16
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0
|
|
17
|
+
MAX_BATCH_CONTACTS = 500
|
|
18
|
+
EMAIL_KINDS = ("find", "verify")
|
|
19
|
+
CSV_COLUMNS = ("first_name", "last_name", "domain")
|
|
20
|
+
|
|
21
|
+
FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
|
|
22
|
+
WAIT_HELP = "Block until the job finishes, streaming progress to stderr."
|
|
23
|
+
TIMEOUT_HELP = "Max seconds to wait with --wait."
|
|
24
|
+
KIND_HELP = "Batch kind: find or verify (verify batches are created by the DiscoLike app)."
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
help=(
|
|
28
|
+
"Find work email addresses: submit single or batch find jobs, poll them, and fetch results. "
|
|
29
|
+
"Only proven addresses bill; catch-all and pattern guesses are free."
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _job_status_to_stderr(status: EmailJobResult) -> None:
|
|
35
|
+
sys.stderr.write(f"status: {status.status}\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _batch_progress_to_stderr(results: EmailBatchResults) -> None:
|
|
39
|
+
sys.stderr.write(f"progress: {results.completed}/{results.total} completed, {results.failed} failed\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_contact(value: str) -> dict[str, str]:
|
|
43
|
+
parts = [part.strip() for part in value.split(",")]
|
|
44
|
+
if len(parts) != len(CSV_COLUMNS) or not all(parts):
|
|
45
|
+
raise typer.BadParameter(f'--contact must be "first_name,last_name,domain", got {value!r}')
|
|
46
|
+
return dict(zip(CSV_COLUMNS, parts, strict=True))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_contacts_file(contacts_file: pathlib.Path) -> list[dict[str, str]]:
|
|
50
|
+
with contacts_file.open(newline="") as handle:
|
|
51
|
+
reader = csv.DictReader(handle)
|
|
52
|
+
missing = set(CSV_COLUMNS) - set(reader.fieldnames or [])
|
|
53
|
+
if missing:
|
|
54
|
+
raise typer.BadParameter(f"--contacts-file is missing required CSV columns: {', '.join(sorted(missing))}")
|
|
55
|
+
return [{column: (row.get(column) or "").strip() for column in CSV_COLUMNS} for row in reader]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fetch_batch_snapshot(batch: EmailBatch) -> EmailBatchResults:
|
|
59
|
+
"""Fetch the batch results once, whether or not the batch has finished."""
|
|
60
|
+
snapshot: dict[str, EmailBatchResults] = {}
|
|
61
|
+
try:
|
|
62
|
+
return batch.results(timeout=0, on_poll=lambda results: snapshot.update(latest=results))
|
|
63
|
+
except JobTimeoutError:
|
|
64
|
+
return snapshot["latest"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("find")
|
|
68
|
+
@handle_errors
|
|
69
|
+
def find_command(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
first_name: str = typer.Argument(..., help="First name of the person."),
|
|
72
|
+
last_name: str = typer.Argument(..., help="Last name of the person."),
|
|
73
|
+
domain: str = typer.Argument(..., help="Company domain to search, e.g. acme.com."),
|
|
74
|
+
known_pattern: str | None = typer.Option(
|
|
75
|
+
None, "--known-pattern", help="Known email local-part pattern for this domain, e.g. first.last."
|
|
76
|
+
),
|
|
77
|
+
wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP),
|
|
78
|
+
timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
|
|
79
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Submit a single email find job (async); only a proven address bills."""
|
|
82
|
+
from discolike_cli.main import get_client
|
|
83
|
+
|
|
84
|
+
job = get_client(ctx).email.find(
|
|
85
|
+
first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern
|
|
86
|
+
)
|
|
87
|
+
if not wait:
|
|
88
|
+
emit({"job_id": job.job_id, "hint": f"poll with: discolike email job {job.job_id}"})
|
|
89
|
+
return
|
|
90
|
+
emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("find-batch")
|
|
94
|
+
@handle_errors
|
|
95
|
+
def find_batch_command(
|
|
96
|
+
ctx: typer.Context,
|
|
97
|
+
contacts_file: pathlib.Path | None = typer.Option(
|
|
98
|
+
None,
|
|
99
|
+
"--contacts-file",
|
|
100
|
+
help="Path to a CSV file with first_name,last_name,domain columns (max 500 contacts per batch).",
|
|
101
|
+
),
|
|
102
|
+
contact: list[str] | None = typer.Option(
|
|
103
|
+
None, "--contact", help='Inline contact as "first_name,last_name,domain" (repeatable).'
|
|
104
|
+
),
|
|
105
|
+
wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP),
|
|
106
|
+
timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
|
|
107
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Submit an email find batch from a CSV file and/or inline contacts (async)."""
|
|
110
|
+
from discolike_cli.main import get_client
|
|
111
|
+
|
|
112
|
+
contacts: list[dict[str, str]] = []
|
|
113
|
+
if contacts_file is not None:
|
|
114
|
+
contacts.extend(_read_contacts_file(contacts_file))
|
|
115
|
+
contacts.extend(_parse_contact(value) for value in contact or [])
|
|
116
|
+
if not contacts:
|
|
117
|
+
raise typer.BadParameter("provide --contacts-file and/or at least one --contact")
|
|
118
|
+
if len(contacts) > MAX_BATCH_CONTACTS:
|
|
119
|
+
raise typer.BadParameter(f"a batch holds at most {MAX_BATCH_CONTACTS} contacts, got {len(contacts)}")
|
|
120
|
+
|
|
121
|
+
batch = get_client(ctx).email.find_batch(contacts=contacts)
|
|
122
|
+
if not wait:
|
|
123
|
+
emit({"batch_id": batch.batch_id, "hint": f"fetch with: discolike email results {batch.batch_id}"})
|
|
124
|
+
return
|
|
125
|
+
emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command("results")
|
|
129
|
+
@handle_errors
|
|
130
|
+
def results_command(
|
|
131
|
+
ctx: typer.Context,
|
|
132
|
+
batch_id: str = typer.Argument(..., help="Batch ID returned by `discolike email find-batch`."),
|
|
133
|
+
kind: str = typer.Option("find", "--kind", help=KIND_HELP),
|
|
134
|
+
wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP),
|
|
135
|
+
timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
|
|
136
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Fetch results for an email find or verify batch."""
|
|
139
|
+
from discolike_cli.main import get_client
|
|
140
|
+
|
|
141
|
+
if kind not in EMAIL_KINDS:
|
|
142
|
+
raise typer.BadParameter(f"--kind must be one of: {', '.join(EMAIL_KINDS)}")
|
|
143
|
+
batch = get_client(ctx).email.batch(batch_id, kind=kind) # type: ignore[arg-type]
|
|
144
|
+
if wait:
|
|
145
|
+
emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt)
|
|
146
|
+
return
|
|
147
|
+
emit(_fetch_batch_snapshot(batch), fmt=fmt)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("job")
|
|
151
|
+
@handle_errors
|
|
152
|
+
def job_command(
|
|
153
|
+
ctx: typer.Context,
|
|
154
|
+
job_id: str = typer.Argument(..., help="Job ID returned by `discolike email find`."),
|
|
155
|
+
wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP),
|
|
156
|
+
timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP),
|
|
157
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Poll a single email find job: print its current status, or block with --wait."""
|
|
160
|
+
from discolike_cli.main import get_client
|
|
161
|
+
|
|
162
|
+
job = get_client(ctx).email.job(job_id)
|
|
163
|
+
if wait:
|
|
164
|
+
emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt)
|
|
165
|
+
return
|
|
166
|
+
emit(job.status(), fmt=fmt)
|
|
@@ -13,6 +13,7 @@ from discolike_cli import company
|
|
|
13
13
|
from discolike_cli import contacts
|
|
14
14
|
from discolike_cli import discogen
|
|
15
15
|
from discolike_cli import discover
|
|
16
|
+
from discolike_cli import email
|
|
16
17
|
from discolike_cli import enrich
|
|
17
18
|
from discolike_cli import match
|
|
18
19
|
from discolike_cli import providers
|
|
@@ -23,7 +24,7 @@ app = typer.Typer(
|
|
|
23
24
|
no_args_is_help=True,
|
|
24
25
|
rich_markup_mode="rich",
|
|
25
26
|
help=(
|
|
26
|
-
"🪩 [bold]DiscoLike[/bold] —
|
|
27
|
+
"🪩 [bold]DiscoLike[/bold] — the search engine for the business web.\n\n"
|
|
27
28
|
"Discover lookalike companies, size segments, enrich domain lists, match messy "
|
|
28
29
|
"company names to domains, and find the right contacts — from your terminal.\n\n"
|
|
29
30
|
"Docs: https://docs.discolike.com · Keys: https://app.discolike.com/account/management/keys"
|
|
@@ -57,6 +58,7 @@ app.add_typer(auth.app, name="auth")
|
|
|
57
58
|
app.add_typer(company.app, name="company")
|
|
58
59
|
app.add_typer(contacts.app, name="contacts")
|
|
59
60
|
app.add_typer(discogen.app, name="discogen")
|
|
61
|
+
app.add_typer(email.app, name="email")
|
|
60
62
|
app.add_typer(queries.app, name="queries")
|
|
61
63
|
app.add_typer(account.app, name="account")
|
|
62
64
|
app.add_typer(providers.search_providers_app, name="search-providers")
|
|
File without changes
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
3
8
|
import typer
|
|
4
9
|
|
|
5
10
|
from discolike_cli._output import emit
|
|
@@ -10,6 +15,14 @@ FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls
|
|
|
10
15
|
app = typer.Typer(help="Manage saved queries and exclusion lists for reusable targeting.")
|
|
11
16
|
|
|
12
17
|
|
|
18
|
+
class SaveResultsAction(str, Enum):
|
|
19
|
+
discover = "discover"
|
|
20
|
+
segment = "segment"
|
|
21
|
+
contacts = "contacts"
|
|
22
|
+
append = "append"
|
|
23
|
+
match = "match"
|
|
24
|
+
|
|
25
|
+
|
|
13
26
|
@app.command("list")
|
|
14
27
|
@handle_errors
|
|
15
28
|
def list_command(
|
|
@@ -48,6 +61,45 @@ def create_exclusion_list_command(
|
|
|
48
61
|
)
|
|
49
62
|
|
|
50
63
|
|
|
64
|
+
@app.command("save-results")
|
|
65
|
+
@handle_errors
|
|
66
|
+
def save_results_command(
|
|
67
|
+
ctx: typer.Context,
|
|
68
|
+
input_path: Path = typer.Option(
|
|
69
|
+
..., "--input", help="Path to a .json (list of row objects) or .csv (header row) file."
|
|
70
|
+
),
|
|
71
|
+
name: str = typer.Option(..., "--name", help="Name for the saved query."),
|
|
72
|
+
action: SaveResultsAction = typer.Option(
|
|
73
|
+
..., "--action", help="Underlying action: discover, segment, contacts, append, or match."
|
|
74
|
+
),
|
|
75
|
+
domain_column: str = typer.Option("domain", "--domain-column", help="Column holding domains."),
|
|
76
|
+
tag: list[str] | None = typer.Option(None, "--tag", help="Tag to attach (repeatable)."),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Save result rows from a file as a reusable saved query."""
|
|
79
|
+
from discolike_cli.main import get_client
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
if input_path.suffix.lower() == ".csv":
|
|
83
|
+
with input_path.open(newline="") as fh:
|
|
84
|
+
data = [dict(row) for row in csv.DictReader(fh)]
|
|
85
|
+
else:
|
|
86
|
+
data = json.loads(input_path.read_text())
|
|
87
|
+
except FileNotFoundError as exc:
|
|
88
|
+
raise typer.BadParameter(f"--input file not found: {input_path}") from exc
|
|
89
|
+
except json.JSONDecodeError as exc:
|
|
90
|
+
raise typer.BadParameter(f"--input file {input_path} must contain valid JSON: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
emit(
|
|
93
|
+
get_client(ctx).queries.save_results(
|
|
94
|
+
query_name=name,
|
|
95
|
+
action=action.value,
|
|
96
|
+
data=data,
|
|
97
|
+
domain_column=domain_column,
|
|
98
|
+
tags=tag,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
51
103
|
@app.command("update")
|
|
52
104
|
@handle_errors
|
|
53
105
|
def update_command(
|
|
@@ -6,24 +6,36 @@ the ``discolike-testkit`` plugin.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
9
10
|
|
|
10
|
-
import
|
|
11
|
+
import httpx2
|
|
11
12
|
import pytest
|
|
12
13
|
|
|
13
14
|
import discolike_cli.main as cli_main
|
|
14
15
|
from discolike import Discolike
|
|
15
16
|
|
|
16
|
-
Handler = Callable[[
|
|
17
|
+
Handler = Callable[[httpx2.Request], httpx2.Response]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def build_client_calls() -> list[dict[str, Any]]:
|
|
22
|
+
"""Records the keyword arguments the CLI passes to its client factory."""
|
|
23
|
+
return []
|
|
17
24
|
|
|
18
25
|
|
|
19
26
|
@pytest.fixture
|
|
20
27
|
def install_build_client(
|
|
21
28
|
monkeypatch: pytest.MonkeyPatch,
|
|
22
29
|
make_client: Callable[[Handler], Discolike],
|
|
30
|
+
build_client_calls: list[dict[str, Any]],
|
|
23
31
|
) -> Callable[[Handler], None]:
|
|
24
32
|
"""Point the CLI's client factory at a mock transport driven by ``handler``."""
|
|
25
33
|
|
|
26
34
|
def _install(handler: Handler) -> None:
|
|
27
|
-
|
|
35
|
+
def _factory(**kwargs: Any) -> Discolike:
|
|
36
|
+
build_client_calls.append(kwargs)
|
|
37
|
+
return make_client(handler)
|
|
38
|
+
|
|
39
|
+
monkeypatch.setattr(cli_main, "build_client", _factory)
|
|
28
40
|
|
|
29
41
|
return _install
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import httpx2
|
|
7
7
|
from typer.testing import CliRunner
|
|
8
8
|
|
|
9
9
|
from discolike_cli.main import app
|
|
@@ -13,9 +13,9 @@ runner = CliRunner()
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def test_account_usage_hits_usage_endpoint(install_build_client: Callable[[Handler], None]) -> None:
|
|
16
|
-
def handler(request:
|
|
16
|
+
def handler(request: httpx2.Request) -> httpx2.Response:
|
|
17
17
|
assert request.url.path == "/v1/usage"
|
|
18
|
-
return
|
|
18
|
+
return httpx2.Response(200, json={"requests_mtd": 100, "records_mtd": 500, "spend_mtd": 12.5})
|
|
19
19
|
|
|
20
20
|
install_build_client(handler)
|
|
21
21
|
result = runner.invoke(app, ["account", "usage"])
|