discolike-cli 0.2.0__tar.gz → 0.3.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 (36) hide show
  1. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/PKG-INFO +3 -3
  2. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/README.md +1 -1
  3. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/pyproject.toml +2 -2
  4. discolike_cli-0.3.0/src/discolike_cli/_loopback.py +63 -0
  5. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/_output.py +28 -5
  6. discolike_cli-0.3.0/src/discolike_cli/auth.py +285 -0
  7. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/company.py +32 -8
  8. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/contacts.py +36 -25
  9. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/discogen.py +32 -20
  10. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/discover.py +54 -48
  11. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/email.py +18 -4
  12. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/enrich.py +30 -15
  13. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/match.py +22 -12
  14. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/providers.py +36 -32
  15. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/queries.py +20 -17
  16. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_auth.py +2 -2
  17. discolike_cli-0.3.0/tests/test_auth_oauth.py +411 -0
  18. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_company.py +19 -7
  19. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_contacts_cli.py +24 -12
  20. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_discover.py +22 -6
  21. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_email_cli.py +27 -0
  22. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_enrich_cli.py +20 -2
  23. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_output.py +44 -0
  24. discolike_cli-0.2.0/src/discolike_cli/auth.py +0 -81
  25. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/.gitignore +0 -0
  26. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/LICENSE +0 -0
  27. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/__init__.py +0 -0
  28. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/account.py +0 -0
  29. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/main.py +0 -0
  30. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/py.typed +0 -0
  31. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/conftest.py +0 -0
  32. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_account.py +0 -0
  33. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_discogen_cli.py +0 -0
  34. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_match_cli.py +0 -0
  35. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_providers_cli.py +0 -0
  36. {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_queries_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: discolike-cli
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -25,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.14
25
25
  Classifier: Topic :: Utilities
26
26
  Classifier: Typing :: Typed
27
27
  Requires-Python: >=3.10
28
- Requires-Dist: discolike==0.2.0
28
+ Requires-Dist: discolike==0.3.0
29
29
  Requires-Dist: rich>=13.0
30
30
  Requires-Dist: typer>=0.12
31
31
  Description-Content-Type: text/markdown
@@ -54,7 +54,7 @@ Requires Python 3.10+. Installing this package gives you the `discolike` command
54
54
  discolike auth login
55
55
  ```
56
56
 
57
- Prompts for an API key (or pass `--api-key`) and verifies it against your account. Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead.
57
+ Opens your browser to log in (add `--no-browser` to print the URL instead, `--port` to pin the loopback port when forwarding over SSH). To use an API key instead, pass `--api-key KEY` or `--method api_key` to be prompted; create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead.
58
58
 
59
59
  ## Quickstart
60
60
 
@@ -22,7 +22,7 @@ Requires Python 3.10+. Installing this package gives you the `discolike` command
22
22
  discolike auth login
23
23
  ```
24
24
 
25
- Prompts for an API key (or pass `--api-key`) and verifies it against your account. Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead.
25
+ Opens your browser to log in (add `--no-browser` to print the URL instead, `--port` to pin the loopback port when forwarding over SSH). To use an API key instead, pass `--api-key KEY` or `--method api_key` to be prompted; create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead.
26
26
 
27
27
  ## Quickstart
28
28
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "discolike-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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.10"
13
13
  authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
14
14
  dependencies = [
15
- "discolike==0.2.0",
15
+ "discolike==0.3.0",
16
16
  "typer>=0.12",
17
17
  "rich>=13.0",
18
18
  ]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from http import HTTPStatus
5
+ from http.server import BaseHTTPRequestHandler
6
+ from http.server import HTTPServer
7
+ from urllib.parse import parse_qs
8
+ from urllib.parse import urlparse
9
+
10
+ LOOPBACK_HOST = "127.0.0.1"
11
+ CALLBACK_PATH = "/callback"
12
+ CALLBACK_HTML = "<!doctype html><title>DiscoLike</title><p>Login complete. You can close this window.</p>"
13
+
14
+
15
+ class _LoopbackServer(HTTPServer):
16
+ def __init__(self, *, host: str, port: int) -> None:
17
+ super().__init__((host, port), _CallbackHandler)
18
+ self.query: dict[str, str] = {}
19
+ self.received = threading.Event()
20
+
21
+
22
+ class _CallbackHandler(BaseHTTPRequestHandler):
23
+ server: _LoopbackServer
24
+
25
+ def do_GET(self) -> None:
26
+ parsed = urlparse(self.path)
27
+ if parsed.path != CALLBACK_PATH:
28
+ self.send_error(HTTPStatus.NOT_FOUND)
29
+ return
30
+ self.server.query = {key: values[0] for key, values in parse_qs(parsed.query).items()}
31
+ body = CALLBACK_HTML.encode()
32
+ self.send_response(HTTPStatus.OK)
33
+ self.send_header("Content-Type", "text/html; charset=utf-8")
34
+ self.send_header("Content-Length", str(len(body)))
35
+ self.end_headers()
36
+ self.wfile.write(body)
37
+ self.server.received.set()
38
+
39
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002 -- BaseHTTPRequestHandler signature
40
+ _ = (format, args)
41
+
42
+
43
+ class CallbackServer:
44
+ """Loopback redirect target for the authorization-code flow; serves on a daemon thread."""
45
+
46
+ def __init__(self, *, port: int) -> None:
47
+ self._server = _LoopbackServer(host=LOOPBACK_HOST, port=port)
48
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
49
+
50
+ @property
51
+ def redirect_uri(self) -> str:
52
+ return f"http://{LOOPBACK_HOST}:{self._server.server_port}{CALLBACK_PATH}"
53
+
54
+ def wait(self, *, timeout: float) -> dict[str, str] | None:
55
+ return self._server.query if self._server.received.wait(timeout) else None
56
+
57
+ def __enter__(self) -> CallbackServer:
58
+ self._thread.start()
59
+ return self
60
+
61
+ def __exit__(self, *exc_info: object) -> None:
62
+ self._server.shutdown()
63
+ self._server.server_close()
@@ -3,12 +3,15 @@ from __future__ import annotations
3
3
  import functools
4
4
  import json
5
5
  import sys
6
+ import types
7
+ import typing
6
8
  from collections.abc import Callable
7
9
  from typing import Any
8
10
  from typing import ParamSpec
9
11
  from typing import Protocol
10
12
  from typing import TypeVar
11
13
 
14
+ import pydantic
12
15
  import typer
13
16
  from rich.console import Console
14
17
  from rich.table import Table
@@ -24,6 +27,8 @@ from discolike._models import DiscolikeModel
24
27
 
25
28
  P = ParamSpec("P")
26
29
  R = TypeVar("R")
30
+ RequestT = TypeVar("RequestT", bound=pydantic.BaseModel)
31
+ UNION_ORIGINS = (typing.Union, types.UnionType)
27
32
 
28
33
  EXIT_CODES: dict[type, int] = {
29
34
  ValidationError: 2,
@@ -117,11 +122,27 @@ def fail(exc: DiscolikeError) -> typer.Exit:
117
122
  return typer.Exit(code=EXIT_CODES.get(type(exc), DEFAULT_EXIT_CODE))
118
123
 
119
124
 
120
- def call_typed(fn: Callable[..., R], **kwargs: Any) -> R: # noqa: ANN401 -- forwarded to a typed SDK method signature
121
- try:
122
- return fn(**kwargs)
123
- except TypeError as exc:
124
- raise typer.BadParameter(str(exc)) from exc
125
+ def _accepts_list(model: type[pydantic.BaseModel], name: str) -> bool:
126
+ field = model.model_fields.get(name)
127
+ if field is None:
128
+ return False
129
+ annotation = field.annotation
130
+ members = typing.get_args(annotation) if typing.get_origin(annotation) in UNION_ORIGINS else (annotation,)
131
+ return any(typing.get_origin(member) is list for member in members)
132
+
133
+
134
+ def build_request(model: type[RequestT], kwargs: dict[str, Any]) -> RequestT:
135
+ # --param KEY=VALUE yields a bare string; the API reads one value for a list param as a one-element list.
136
+ return model.model_validate(
137
+ {
138
+ key: [value] if isinstance(value, str) and _accepts_list(model, key) else value
139
+ for key, value in kwargs.items()
140
+ }
141
+ )
142
+
143
+
144
+ def _validation_message(exc: pydantic.ValidationError) -> str:
145
+ return "; ".join(f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in exc.errors())
125
146
 
126
147
 
127
148
  def handle_errors(fn: Callable[P, R]) -> Callable[P, R]:
@@ -129,6 +150,8 @@ def handle_errors(fn: Callable[P, R]) -> Callable[P, R]:
129
150
  def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
130
151
  try:
131
152
  return fn(*args, **kwargs)
153
+ except pydantic.ValidationError as exc:
154
+ raise fail(ValidationError(_validation_message(exc))) from exc
132
155
  except DiscolikeError as exc:
133
156
  raise fail(exc) from exc
134
157
 
@@ -0,0 +1,285 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ import sys
6
+ import webbrowser
7
+ from datetime import datetime
8
+ from datetime import timezone
9
+ from typing import Any
10
+ from typing import NoReturn
11
+ from urllib.parse import urlparse
12
+
13
+ import httpx2
14
+ import typer
15
+
16
+ from discolike._config import AUTH_METHOD_API_KEY
17
+ from discolike._config import AUTH_METHOD_OAUTH
18
+ from discolike._config import DEFAULT_BASE_URL
19
+ from discolike._config import KEYS_URL
20
+ from discolike._config import NO_CREDENTIAL_MESSAGE
21
+ from discolike._config import delete_credential
22
+ from discolike._config import delete_oauth_client
23
+ from discolike._config import load_credential
24
+ from discolike._config import load_oauth_client
25
+ from discolike._config import save_config
26
+ from discolike._config import save_credential
27
+ from discolike._config import save_oauth_client
28
+ from discolike._credentials import OAuthClientRegistration
29
+ from discolike._credentials import OAuthCredential
30
+ from discolike._exceptions import AuthenticationError
31
+ from discolike._oauth import AuthServerMetadata
32
+ from discolike._oauth import OAuthError
33
+ from discolike._oauth import build_authorization_url
34
+ from discolike._oauth import discover
35
+ from discolike._oauth import exchange_code
36
+ from discolike._oauth import pkce_pair
37
+ from discolike._oauth import register_client
38
+ from discolike_cli._loopback import CallbackServer
39
+ from discolike_cli._output import emit
40
+ from discolike_cli._output import handle_errors
41
+
42
+ app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.")
43
+
44
+ MASKED_VISIBLE_CHARS = 4
45
+ LOGIN_TIMEOUT_SECONDS = 180.0
46
+ OAUTH_HTTP_TIMEOUT_SECONDS = 30.0
47
+ STATE_BYTES = 16
48
+ RANDOM_PORT = 0
49
+
50
+ SOURCE_OPTION = "option"
51
+ SOURCE_ENV = "env"
52
+ SOURCE_CONFIG = "config"
53
+
54
+ LOGIN_METHODS = (AUTH_METHOD_OAUTH, AUTH_METHOD_API_KEY)
55
+ DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"})
56
+
57
+
58
+ class _DeadClientError(Exception):
59
+ """The authorization server no longer recognises the registered client_id."""
60
+
61
+
62
+ def _mask(key: str) -> str:
63
+ return "…" + key[-MASKED_VISIBLE_CHARS:]
64
+
65
+
66
+ def _iso(epoch_seconds: float) -> str:
67
+ return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat()
68
+
69
+
70
+ def _global_key_source(ctx: typer.Context) -> str:
71
+ # typer vendors click without re-exporting ParameterSource, so match on the enum member name.
72
+ source = ctx.find_root().get_parameter_source("api_key")
73
+ return SOURCE_ENV if source is not None and source.name == "ENVIRONMENT" else SOURCE_OPTION
74
+
75
+
76
+ def _verify(ctx: typer.Context, **kwargs: Any) -> None: # noqa: ANN401 -- forwarded verbatim to build_client
77
+ from discolike_cli.main import build_client
78
+
79
+ base_url = ctx.obj.get("base_url")
80
+ if base_url is not None:
81
+ kwargs["base_url"] = base_url
82
+ build_client(**kwargs).account.usage()
83
+
84
+
85
+ def _abort_login(message: str) -> NoReturn:
86
+ print(json.dumps({"error": "LoginError", "message": message}), file=sys.stderr)
87
+ raise typer.Exit(code=1)
88
+
89
+
90
+ def _registered_port(registration: OAuthClientRegistration) -> int:
91
+ return int(urlparse(registration.redirect_uri).port or RANDOM_PORT)
92
+
93
+
94
+ def _reusable_registration(*, issuer: str, port: int) -> OAuthClientRegistration | None:
95
+ stored = load_oauth_client()
96
+ if stored is None or stored.issuer != issuer:
97
+ return None
98
+ if port != RANDOM_PORT and _registered_port(stored) != port:
99
+ return None
100
+ return stored
101
+
102
+
103
+ def _bind_stored_port(registration: OAuthClientRegistration) -> CallbackServer | None:
104
+ try:
105
+ return CallbackServer(port=_registered_port(registration))
106
+ except OSError:
107
+ return None
108
+
109
+
110
+ def _register_or_reuse(
111
+ metadata: AuthServerMetadata, *, port: int, http: httpx2.Client
112
+ ) -> tuple[CallbackServer, OAuthClientRegistration, bool]:
113
+ # PropelAuth matches the redirect URI literally (port included) and remembers consent per client_id,
114
+ # so a stored registration is only worth reusing when its exact port can be bound again.
115
+ stored = _reusable_registration(issuer=metadata.issuer, port=port)
116
+ if stored is not None:
117
+ server = _bind_stored_port(stored)
118
+ if server is not None:
119
+ return server, stored, True
120
+ server = CallbackServer(port=port)
121
+ client_id = register_client(metadata, redirect_uris=[server.redirect_uri], client=http)
122
+ registration = OAuthClientRegistration(
123
+ client_id=client_id, redirect_uri=server.redirect_uri, issuer=metadata.issuer
124
+ )
125
+ save_oauth_client(registration)
126
+ return server, registration, False
127
+
128
+
129
+ def _authorize(
130
+ metadata: AuthServerMetadata,
131
+ registration: OAuthClientRegistration,
132
+ server: CallbackServer,
133
+ *,
134
+ resource: str,
135
+ open_browser: bool,
136
+ http: httpx2.Client,
137
+ ) -> OAuthCredential:
138
+ verifier, challenge = pkce_pair()
139
+ state = secrets.token_urlsafe(STATE_BYTES)
140
+ url = build_authorization_url(
141
+ metadata,
142
+ client_id=registration.client_id,
143
+ redirect_uri=registration.redirect_uri,
144
+ code_challenge=challenge,
145
+ state=state,
146
+ resource=resource,
147
+ )
148
+ print(f"Open this URL in your browser to log in:\n{url}", file=sys.stderr)
149
+ if open_browser and not webbrowser.open(url):
150
+ print("Could not open a browser; open the URL above manually.", file=sys.stderr)
151
+ callback = server.wait(timeout=LOGIN_TIMEOUT_SECONDS)
152
+ if callback is None:
153
+ _abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login")
154
+ if callback.get("state") != state:
155
+ _abort_login("Invalid OAuth callback (state mismatch)")
156
+ if "error" in callback:
157
+ message = f"Authorization failed: {callback.get('error_description') or callback['error']}"
158
+ if callback["error"] in DEAD_CLIENT_ERRORS:
159
+ raise _DeadClientError(message)
160
+ _abort_login(message)
161
+ if "code" not in callback:
162
+ _abort_login("Invalid OAuth callback (missing code)")
163
+ try:
164
+ return exchange_code(
165
+ metadata,
166
+ client_id=registration.client_id,
167
+ code=callback["code"],
168
+ code_verifier=verifier,
169
+ redirect_uri=registration.redirect_uri,
170
+ resource=resource,
171
+ client=http,
172
+ )
173
+ except OAuthError as exc:
174
+ if exc.error in DEAD_CLIENT_ERRORS:
175
+ raise _DeadClientError(f"Token exchange failed: {exc}") from exc
176
+ raise
177
+
178
+
179
+ def _oauth_login(ctx: typer.Context, *, open_browser: bool, port: int) -> OAuthCredential:
180
+ base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
181
+ with httpx2.Client(timeout=OAUTH_HTTP_TIMEOUT_SECONDS) as http:
182
+ metadata = discover(base_url, client=http)
183
+ server, registration, reused = _register_or_reuse(metadata, port=port, http=http)
184
+ try:
185
+ with server:
186
+ return _authorize(
187
+ metadata, registration, server, resource=base_url, open_browser=open_browser, http=http
188
+ )
189
+ except _DeadClientError as exc:
190
+ if not reused:
191
+ _abort_login(str(exc))
192
+ delete_oauth_client()
193
+ server, registration, _ = _register_or_reuse(metadata, port=port, http=http)
194
+ try:
195
+ with server:
196
+ return _authorize(
197
+ metadata, registration, server, resource=base_url, open_browser=open_browser, http=http
198
+ )
199
+ except _DeadClientError as exc:
200
+ _abort_login(str(exc))
201
+
202
+
203
+ def _api_key_login(ctx: typer.Context, *, api_key: str | None) -> None:
204
+ # An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may.
205
+ passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None
206
+ key = api_key or passed_globally or typer.prompt("API key", hide_input=True)
207
+ _verify(ctx, api_key=key)
208
+ save_config({"auth_method": AUTH_METHOD_API_KEY, "api_key": key})
209
+ print(json.dumps({"logged_in": True, "source": AUTH_METHOD_API_KEY}), file=sys.stderr)
210
+
211
+
212
+ @app.command()
213
+ @handle_errors
214
+ def login(
215
+ ctx: typer.Context,
216
+ api_key: str | None = typer.Option(
217
+ None, help=f"Log in with an API key instead of the browser. Create one at {KEYS_URL}."
218
+ ),
219
+ method: str = typer.Option(
220
+ AUTH_METHOD_OAUTH,
221
+ "--method",
222
+ help="oauth (browser login, default) or api_key (prompts for a key unless --api-key is given).",
223
+ ),
224
+ no_browser: bool = typer.Option(False, "--no-browser", help="Print the login URL instead of opening a browser."),
225
+ port: int = typer.Option(
226
+ RANDOM_PORT, "--port", help="Fixed loopback port for the browser redirect (default: random; use with SSH)."
227
+ ),
228
+ ) -> None:
229
+ """Log in via the browser (OAuth) or with an API key, verify, and save to the local config file."""
230
+ if method not in LOGIN_METHODS:
231
+ raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
232
+ global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
233
+ if api_key or global_key_passed or method == AUTH_METHOD_API_KEY:
234
+ _api_key_login(ctx, api_key=api_key)
235
+ return
236
+ credential = _oauth_login(ctx, open_browser=not no_browser, port=port)
237
+ _verify(ctx, auth=credential)
238
+ save_credential(credential)
239
+ print(
240
+ json.dumps({"logged_in": True, "method": AUTH_METHOD_OAUTH, "expires_at": _iso(credential.expires_at)}),
241
+ file=sys.stderr,
242
+ )
243
+
244
+
245
+ @app.command()
246
+ @handle_errors
247
+ def status(ctx: typer.Context) -> None:
248
+ """Show which credential is in use (option, env, or config) and verify it against the API."""
249
+ key = ctx.obj.get("api_key")
250
+ if key:
251
+ _verify(ctx, api_key=str(key))
252
+ emit(
253
+ {
254
+ "source": _global_key_source(ctx),
255
+ "method": AUTH_METHOD_API_KEY,
256
+ "api_key": _mask(str(key)),
257
+ "valid": True,
258
+ }
259
+ )
260
+ return
261
+ credential = load_credential()
262
+ if credential is None:
263
+ raise AuthenticationError(NO_CREDENTIAL_MESSAGE)
264
+ if isinstance(credential, OAuthCredential):
265
+ _verify(ctx)
266
+ emit(
267
+ {
268
+ "source": SOURCE_CONFIG,
269
+ "method": AUTH_METHOD_OAUTH,
270
+ "expires_at": _iso(credential.expires_at),
271
+ "expired": credential.expires_within(0),
272
+ "valid": True,
273
+ }
274
+ )
275
+ return
276
+ _verify(ctx, api_key=credential.api_key)
277
+ emit({"source": SOURCE_CONFIG, "method": AUTH_METHOD_API_KEY, "api_key": _mask(credential.api_key), "valid": True})
278
+
279
+
280
+ @app.command()
281
+ @handle_errors
282
+ def logout() -> None:
283
+ """Delete saved credentials from the local config file (the registered OAuth client is kept)."""
284
+ delete_credential()
285
+ emit({"logged_out": True})
@@ -2,8 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import typer
4
4
 
5
+ from discolike.requests import CompaniesDataParams
6
+ from discolike.requests import CompaniesExtractParams
7
+ from discolike.requests import CompaniesGrowthParams
8
+ from discolike.requests import CompaniesPublicLinksParams
9
+ from discolike.requests import CompaniesRedirectsParams
10
+ from discolike.requests import CompaniesScoreParams
11
+ from discolike.requests import CompaniesSubsidiariesParams
12
+ from discolike.requests import CompaniesVendorsParams
13
+ from discolike_cli._output import build_request
5
14
  from discolike_cli._output import emit
6
15
  from discolike_cli._output import handle_errors
16
+ from discolike_cli.discover import _merge_params
7
17
 
8
18
  FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
9
19
  DOMAIN_HELP = "Company domain, e.g. stripe.com"
@@ -24,7 +34,9 @@ def data(
24
34
  """Full company profile (firmographics) for a domain."""
25
35
  from discolike_cli.main import get_client
26
36
 
27
- emit(get_client(ctx).companies.data(domain=domain), fmt=fmt)
37
+ emit(
38
+ get_client(ctx).companies.data(build_request(CompaniesDataParams, _merge_params(None, domain=domain))), fmt=fmt
39
+ )
28
40
 
29
41
 
30
42
  @app.command()
@@ -37,7 +49,10 @@ def score(
37
49
  """Company score for a domain."""
38
50
  from discolike_cli.main import get_client
39
51
 
40
- emit(get_client(ctx).companies.score(domain=domain), fmt=fmt)
52
+ emit(
53
+ get_client(ctx).companies.score(build_request(CompaniesScoreParams, _merge_params(None, domain=domain))),
54
+ fmt=fmt,
55
+ )
41
56
 
42
57
 
43
58
  @app.command()
@@ -50,7 +65,10 @@ def growth(
50
65
  """Growth signals for a domain."""
51
66
  from discolike_cli.main import get_client
52
67
 
53
- emit(get_client(ctx).companies.growth(domain=domain), fmt=fmt)
68
+ emit(
69
+ get_client(ctx).companies.growth(build_request(CompaniesGrowthParams, _merge_params(None, domain=domain))),
70
+ fmt=fmt,
71
+ )
54
72
 
55
73
 
56
74
  @app.command()
@@ -64,7 +82,8 @@ def redirects(
64
82
  """Domain redirects for a company domain."""
65
83
  from discolike_cli.main import get_client
66
84
 
67
- emit(get_client(ctx).companies.redirects(domain=domain, match=match), fmt=fmt)
85
+ request = build_request(CompaniesRedirectsParams, _merge_params(None, domain=domain, match=match))
86
+ emit(get_client(ctx).companies.redirects(request), fmt=fmt)
68
87
 
69
88
 
70
89
  @app.command()
@@ -78,7 +97,8 @@ def vendors(
78
97
  """Vendors associated with a company domain."""
79
98
  from discolike_cli.main import get_client
80
99
 
81
- emit(get_client(ctx).companies.vendors(domain=domain, match=match), fmt=fmt)
100
+ request = build_request(CompaniesVendorsParams, _merge_params(None, domain=domain, match=match))
101
+ emit(get_client(ctx).companies.vendors(request), fmt=fmt)
82
102
 
83
103
 
84
104
  @app.command()
@@ -92,7 +112,8 @@ def subsidiaries(
92
112
  """Subsidiaries of a company domain."""
93
113
  from discolike_cli.main import get_client
94
114
 
95
- emit(get_client(ctx).companies.subsidiaries(domain=domain, match=match), fmt=fmt)
115
+ request = build_request(CompaniesSubsidiariesParams, _merge_params(None, domain=domain, match=match))
116
+ emit(get_client(ctx).companies.subsidiaries(request), fmt=fmt)
96
117
 
97
118
 
98
119
  @app.command(name="public-links")
@@ -106,7 +127,8 @@ def public_links(
106
127
  """Public profile links for a domain from a given source."""
107
128
  from discolike_cli.main import get_client
108
129
 
109
- emit(get_client(ctx).companies.public_links(domain=domain, source=source), fmt=fmt)
130
+ request = build_request(CompaniesPublicLinksParams, _merge_params(None, domain=domain, source=source))
131
+ emit(get_client(ctx).companies.public_links(request), fmt=fmt)
110
132
 
111
133
 
112
134
  @handle_errors
@@ -118,4 +140,6 @@ def extract_command(
118
140
  """Extract page content from a URL."""
119
141
  from discolike_cli.main import get_client
120
142
 
121
- emit(get_client(ctx).companies.extract(url=url), fmt=fmt)
143
+ emit(
144
+ get_client(ctx).companies.extract(build_request(CompaniesExtractParams, _merge_params(None, url=url))), fmt=fmt
145
+ )
@@ -5,7 +5,14 @@ import pathlib
5
5
 
6
6
  import typer
7
7
 
8
- from discolike_cli._output import call_typed
8
+ from discolike.requests import BulkContactMatchRequest
9
+ from discolike.requests import ContactFilters
10
+ from discolike.requests import ContactGenerateRequest
11
+ from discolike.requests import ContactsCountParams
12
+ from discolike.requests import ContactsLookupParams
13
+ from discolike.requests import ContactsMatchParams
14
+ from discolike.requests import ContactsSearchParams
15
+ from discolike_cli._output import build_request
9
16
  from discolike_cli._output import emit
10
17
  from discolike_cli._output import handle_errors
11
18
  from discolike_cli._output import run_job
@@ -77,7 +84,7 @@ def search_command(
77
84
  max_records=max_records,
78
85
  offset=offset,
79
86
  )
80
- emit(call_typed(get_client(ctx).contacts.search, **kwargs), fmt=fmt)
87
+ emit(get_client(ctx).contacts.search(build_request(ContactsSearchParams, kwargs)), fmt=fmt)
81
88
 
82
89
 
83
90
  @app.command("count")
@@ -129,7 +136,7 @@ def count_command(
129
136
  has_email=has_email,
130
137
  jobstart_date=jobstart_date,
131
138
  )
132
- emit(call_typed(get_client(ctx).contacts.count, **kwargs), fmt=fmt)
139
+ emit(get_client(ctx).contacts.count(build_request(ContactsCountParams, kwargs)), fmt=fmt)
133
140
 
134
141
 
135
142
  @app.command("lookup")
@@ -144,7 +151,10 @@ def lookup_command(
144
151
  """Look up a single contact by persona ID, LinkedIn URL, or email."""
145
152
  from discolike_cli.main import get_client
146
153
 
147
- emit(get_client(ctx).contacts.lookup(persona_id=persona_id, linkedin=linkedin, email=email), fmt=fmt)
154
+ request = build_request(
155
+ ContactsLookupParams, _merge_params(None, persona_id=persona_id, linkedin=linkedin, email=email)
156
+ )
157
+ emit(get_client(ctx).contacts.lookup(request), fmt=fmt)
148
158
 
149
159
 
150
160
  @app.command("match")
@@ -161,16 +171,13 @@ def match_command(
161
171
  """Match a person name to contact records."""
162
172
  from discolike_cli.main import get_client
163
173
 
164
- emit(
165
- get_client(ctx).contacts.match(
166
- name=name,
167
- company_name=company_name,
168
- domain=domain,
169
- person_country=person_country,
170
- limit=limit,
174
+ request = build_request(
175
+ ContactsMatchParams,
176
+ _merge_params(
177
+ None, name=name, company_name=company_name, domain=domain, person_country=person_country, limit=limit
171
178
  ),
172
- fmt=fmt,
173
179
  )
180
+ emit(get_client(ctx).contacts.match(request), fmt=fmt)
174
181
 
175
182
 
176
183
  @app.command("bulk-match")
@@ -198,8 +205,8 @@ def bulk_match_command(
198
205
  if not isinstance(queries, list):
199
206
  raise typer.BadParameter("--queries-file must contain a JSON array of objects")
200
207
 
201
- job = get_client(ctx).contacts.bulk_match(queries=queries, enrich=enrich, limit=limit)
202
- run_job(job, wait=wait, timeout=timeout, fmt=fmt)
208
+ request = build_request(BulkContactMatchRequest, _merge_params(None, queries=queries, enrich=enrich, limit=limit))
209
+ run_job(get_client(ctx).contacts.bulk_match(request), wait=wait, timeout=timeout, fmt=fmt)
203
210
 
204
211
 
205
212
  @app.command("discover")
@@ -267,7 +274,7 @@ def discover_command(
267
274
  include_search_contacts=include_search_contacts,
268
275
  consensus=consensus,
269
276
  )
270
- emit(call_typed(get_client(ctx).contacts.discover, **kwargs), fmt=fmt)
277
+ emit(get_client(ctx).contacts.discover(build_request(ContactFilters, kwargs)), fmt=fmt)
271
278
 
272
279
 
273
280
  @app.command("generate")
@@ -297,14 +304,18 @@ def generate_command(
297
304
  """Generate contacts for target domains from an ICP description (async job)."""
298
305
  from discolike_cli.main import get_client
299
306
 
300
- job = get_client(ctx).contacts.generate(
301
- icp_text=icp_text,
302
- domains=domain,
303
- context_mode=context_mode,
304
- integration_id=integration_id,
305
- search_provider_id=search_provider_id,
306
- search_context_size=search_context_size,
307
- max_contacts_per_domain=max_contacts_per_domain,
308
- max_company_records=max_company_records,
307
+ request = build_request(
308
+ ContactGenerateRequest,
309
+ _merge_params(
310
+ None,
311
+ icp_text=icp_text,
312
+ domains=domain,
313
+ context_mode=context_mode,
314
+ integration_id=integration_id,
315
+ search_provider_id=search_provider_id,
316
+ search_context_size=search_context_size,
317
+ max_contacts_per_domain=max_contacts_per_domain,
318
+ max_company_records=max_company_records,
319
+ ),
309
320
  )
310
- run_job(job, wait=wait, timeout=timeout, fmt=fmt)
321
+ run_job(get_client(ctx).contacts.generate(request), wait=wait, timeout=timeout, fmt=fmt)