discolike-cli 0.1.1__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 (39) hide show
  1. discolike_cli-0.3.0/LICENSE +21 -0
  2. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/PKG-INFO +13 -5
  3. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/README.md +2 -2
  4. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/pyproject.toml +10 -2
  5. discolike_cli-0.3.0/src/discolike_cli/_loopback.py +63 -0
  6. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/_output.py +28 -5
  7. discolike_cli-0.3.0/src/discolike_cli/auth.py +285 -0
  8. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/company.py +32 -8
  9. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/contacts.py +36 -25
  10. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/discogen.py +32 -20
  11. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/discover.py +54 -48
  12. discolike_cli-0.3.0/src/discolike_cli/email.py +180 -0
  13. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/enrich.py +30 -15
  14. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/main.py +3 -1
  15. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/match.py +22 -12
  16. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/providers.py +36 -32
  17. discolike_cli-0.3.0/src/discolike_cli/py.typed +0 -0
  18. discolike_cli-0.3.0/src/discolike_cli/queries.py +130 -0
  19. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/conftest.py +15 -3
  20. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_account.py +3 -3
  21. discolike_cli-0.3.0/tests/test_auth.py +210 -0
  22. discolike_cli-0.3.0/tests/test_auth_oauth.py +411 -0
  23. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_company.py +38 -26
  24. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_contacts_cli.py +59 -47
  25. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_discogen_cli.py +25 -25
  26. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_discover.py +39 -23
  27. discolike_cli-0.3.0/tests/test_email_cli.py +310 -0
  28. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_enrich_cli.py +51 -33
  29. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_match_cli.py +29 -29
  30. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_output.py +44 -0
  31. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/tests/test_providers_cli.py +22 -22
  32. discolike_cli-0.3.0/tests/test_queries_cli.py +180 -0
  33. discolike_cli-0.1.1/src/discolike_cli/auth.py +0 -63
  34. discolike_cli-0.1.1/src/discolike_cli/queries.py +0 -75
  35. discolike_cli-0.1.1/tests/test_auth.py +0 -120
  36. discolike_cli-0.1.1/tests/test_queries_cli.py +0 -96
  37. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/.gitignore +0 -0
  38. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/__init__.py +0 -0
  39. {discolike_cli-0.1.1 → discolike_cli-0.3.0}/src/discolike_cli/account.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.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
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.3.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
 
@@ -46,7 +54,7 @@ Requires Python 3.10+. Installing this package gives you the `discolike` command
46
54
  discolike auth login
47
55
  ```
48
56
 
49
- 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.
50
58
 
51
59
  ## Quickstart
52
60
 
@@ -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
 
@@ -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,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.3.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.3.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 }
@@ -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
+ )