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.
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/PKG-INFO +3 -3
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/README.md +1 -1
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/pyproject.toml +2 -2
- discolike_cli-0.3.0/src/discolike_cli/_loopback.py +63 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/_output.py +28 -5
- discolike_cli-0.3.0/src/discolike_cli/auth.py +285 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/company.py +32 -8
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/contacts.py +36 -25
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/discogen.py +32 -20
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/discover.py +54 -48
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/email.py +18 -4
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/enrich.py +30 -15
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/match.py +22 -12
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/providers.py +36 -32
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/queries.py +20 -17
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_auth.py +2 -2
- discolike_cli-0.3.0/tests/test_auth_oauth.py +411 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_company.py +19 -7
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_contacts_cli.py +24 -12
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_discover.py +22 -6
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_email_cli.py +27 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_enrich_cli.py +20 -2
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_output.py +44 -0
- discolike_cli-0.2.0/src/discolike_cli/auth.py +0 -81
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/.gitignore +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/LICENSE +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/__init__.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/account.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/main.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/src/discolike_cli/py.typed +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/conftest.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_account.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_discogen_cli.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_match_cli.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.0}/tests/test_providers_cli.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
202
|
-
run_job(
|
|
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(
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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(
|
|
321
|
+
run_job(get_client(ctx).contacts.generate(request), wait=wait, timeout=timeout, fmt=fmt)
|