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.
Files changed (35) hide show
  1. discolike_cli-0.2.0/LICENSE +21 -0
  2. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/PKG-INFO +12 -4
  3. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/README.md +1 -1
  4. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/pyproject.toml +10 -2
  5. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/auth.py +32 -14
  6. discolike_cli-0.2.0/src/discolike_cli/email.py +166 -0
  7. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/main.py +3 -1
  8. discolike_cli-0.2.0/src/discolike_cli/py.typed +0 -0
  9. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/queries.py +52 -0
  10. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/conftest.py +15 -3
  11. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_account.py +3 -3
  12. discolike_cli-0.2.0/tests/test_auth.py +210 -0
  13. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_company.py +20 -20
  14. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_contacts_cli.py +35 -35
  15. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_discogen_cli.py +25 -25
  16. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_discover.py +17 -17
  17. discolike_cli-0.2.0/tests/test_email_cli.py +283 -0
  18. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_enrich_cli.py +31 -31
  19. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_match_cli.py +29 -29
  20. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/tests/test_providers_cli.py +22 -22
  21. discolike_cli-0.2.0/tests/test_queries_cli.py +180 -0
  22. discolike_cli-0.1.1/tests/test_auth.py +0 -120
  23. discolike_cli-0.1.1/tests/test_queries_cli.py +0 -96
  24. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/.gitignore +0 -0
  25. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/__init__.py +0 -0
  26. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/_output.py +0 -0
  27. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/account.py +0 -0
  28. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/company.py +0 -0
  29. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/contacts.py +0 -0
  30. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/discogen.py +0 -0
  31. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/discover.py +0 -0
  32. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/enrich.py +0 -0
  33. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/match.py +0 -0
  34. {discolike_cli-0.1.1 → discolike_cli-0.2.0}/src/discolike_cli/providers.py +0 -0
  35. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: discolike-cli
3
- Version: 0.1.1
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>=0.1.1
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://www.discolike.com) — the `discolike` command from your terminal. Built on the [`discolike`](https://pypi.org/project/discolike/) SDK.
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://www.discolike.com) — the `discolike` command from your terminal. Built on the [`discolike`](https://pypi.org/project/discolike/) SDK.
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.1.1"
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>=0.1.1",
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
- from discolike_cli.main import build_client
34
-
35
- key = api_key or typer.prompt("API key", hide_input=True)
36
- build_client(api_key=key).account.usage()
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
- from discolike_cli.main import build_client
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 = "config"
69
+ source = SOURCE_CONFIG
52
70
  if not key:
53
71
  resolve_api_key(None)
54
- build_client(api_key=key).account.usage()
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] — from the entire web to your ideal target accounts.\n\n"
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 httpx
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[[httpx.Request], httpx.Response]
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
- monkeypatch.setattr(cli_main, "build_client", lambda **kwargs: make_client(handler))
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 httpx
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: httpx.Request) -> httpx.Response:
16
+ def handler(request: httpx2.Request) -> httpx2.Response:
17
17
  assert request.url.path == "/v1/usage"
18
- return httpx.Response(200, json={"requests_mtd": 100, "records_mtd": 500, "spend_mtd": 12.5})
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"])