discolike-cli 0.2.0__tar.gz → 0.3.1__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.1}/PKG-INFO +3 -3
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/README.md +1 -1
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/pyproject.toml +2 -2
- discolike_cli-0.3.1/src/discolike_cli/_loopback.py +63 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/_output.py +28 -5
- discolike_cli-0.3.1/src/discolike_cli/auth.py +312 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/company.py +32 -8
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/contacts.py +36 -25
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/discogen.py +32 -20
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/discover.py +54 -48
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/email.py +18 -4
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/enrich.py +30 -15
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/main.py +2 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/match.py +22 -12
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/providers.py +36 -32
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/queries.py +20 -17
- discolike_cli-0.3.1/src/discolike_cli/signup.py +76 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_auth.py +2 -2
- discolike_cli-0.3.1/tests/test_auth_oauth.py +497 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_company.py +19 -7
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_contacts_cli.py +24 -12
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_discover.py +22 -6
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_email_cli.py +27 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_enrich_cli.py +20 -2
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_output.py +44 -0
- discolike_cli-0.3.1/tests/test_signup_cli.py +104 -0
- discolike_cli-0.2.0/src/discolike_cli/auth.py +0 -81
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/.gitignore +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/LICENSE +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/__init__.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/account.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/src/discolike_cli/py.typed +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/conftest.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_account.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_discogen_cli.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_match_cli.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/tests/test_providers_cli.py +0 -0
- {discolike_cli-0.2.0 → discolike_cli-0.3.1}/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.1
|
|
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.1
|
|
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.1"
|
|
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.1",
|
|
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,312 @@
|
|
|
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 register_client
|
|
37
|
+
from discolike_cli._loopback import CallbackServer
|
|
38
|
+
from discolike_cli._output import emit
|
|
39
|
+
from discolike_cli._output import handle_errors
|
|
40
|
+
from discolike_cli.signup import _is_interactive
|
|
41
|
+
from discolike_cli.signup import run_signup
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.")
|
|
44
|
+
|
|
45
|
+
MASKED_VISIBLE_CHARS = 4
|
|
46
|
+
LOGIN_TIMEOUT_SECONDS = 180.0
|
|
47
|
+
OAUTH_HTTP_TIMEOUT_SECONDS = 30.0
|
|
48
|
+
STATE_BYTES = 16
|
|
49
|
+
RANDOM_PORT = 0
|
|
50
|
+
|
|
51
|
+
SOURCE_OPTION = "option"
|
|
52
|
+
SOURCE_ENV = "env"
|
|
53
|
+
SOURCE_CONFIG = "config"
|
|
54
|
+
|
|
55
|
+
LOGIN_METHODS = (AUTH_METHOD_OAUTH, AUTH_METHOD_API_KEY)
|
|
56
|
+
DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
HAS_ACCOUNT_PROMPT = "Do you already have a DiscoLike account?"
|
|
60
|
+
SIGNUP_FOLLOWUP_MESSAGE = "Confirm the email, then run `discolike auth login` again to sign in."
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _DeadClientError(Exception):
|
|
64
|
+
"""The authorization server no longer recognises the registered client_id."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _mask(key: str) -> str:
|
|
68
|
+
return "…" + key[-MASKED_VISIBLE_CHARS:]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _was_passed_on_command_line(ctx: typer.Context, name: str) -> bool:
|
|
72
|
+
source = ctx.get_parameter_source(name)
|
|
73
|
+
return source is not None and source.name == "COMMANDLINE"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _offer_signup(ctx: typer.Context) -> None:
|
|
77
|
+
email = typer.prompt("Work email")
|
|
78
|
+
first_name = typer.prompt("First name")
|
|
79
|
+
last_name = typer.prompt("Last name")
|
|
80
|
+
base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
|
|
81
|
+
run_signup(
|
|
82
|
+
email=email, first_name=first_name, last_name=last_name, agent=None, base_url=base_url, yes=False, fmt=None
|
|
83
|
+
)
|
|
84
|
+
typer.echo(SIGNUP_FOLLOWUP_MESSAGE)
|
|
85
|
+
raise typer.Exit(code=0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _iso(epoch_seconds: float) -> str:
|
|
89
|
+
return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _global_key_source(ctx: typer.Context) -> str:
|
|
93
|
+
# typer vendors click without re-exporting ParameterSource, so match on the enum member name.
|
|
94
|
+
source = ctx.find_root().get_parameter_source("api_key")
|
|
95
|
+
return SOURCE_ENV if source is not None and source.name == "ENVIRONMENT" else SOURCE_OPTION
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _verify(ctx: typer.Context, **kwargs: Any) -> None: # noqa: ANN401 -- forwarded verbatim to build_client
|
|
99
|
+
from discolike_cli.main import build_client
|
|
100
|
+
|
|
101
|
+
base_url = ctx.obj.get("base_url")
|
|
102
|
+
if base_url is not None:
|
|
103
|
+
kwargs["base_url"] = base_url
|
|
104
|
+
build_client(**kwargs).account.usage()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _abort_login(message: str) -> NoReturn:
|
|
108
|
+
print(json.dumps({"error": "LoginError", "message": message}), file=sys.stderr)
|
|
109
|
+
raise typer.Exit(code=1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _registered_port(registration: OAuthClientRegistration) -> int:
|
|
113
|
+
return int(urlparse(registration.redirect_uri).port or RANDOM_PORT)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _reusable_registration(*, issuer: str, port: int) -> OAuthClientRegistration | None:
|
|
117
|
+
stored = load_oauth_client()
|
|
118
|
+
if stored is None or stored.issuer != issuer:
|
|
119
|
+
return None
|
|
120
|
+
if port != RANDOM_PORT and _registered_port(stored) != port:
|
|
121
|
+
return None
|
|
122
|
+
return stored
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _bind_stored_port(registration: OAuthClientRegistration) -> CallbackServer | None:
|
|
126
|
+
try:
|
|
127
|
+
return CallbackServer(port=_registered_port(registration))
|
|
128
|
+
except OSError:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _register_or_reuse(
|
|
133
|
+
metadata: AuthServerMetadata, *, port: int, http: httpx2.Client
|
|
134
|
+
) -> tuple[CallbackServer, OAuthClientRegistration, bool]:
|
|
135
|
+
# PropelAuth matches the redirect URI literally (port included) and remembers consent per client_id,
|
|
136
|
+
# so a stored registration is only worth reusing when its exact port can be bound again.
|
|
137
|
+
stored = _reusable_registration(issuer=metadata.issuer, port=port)
|
|
138
|
+
if stored is not None:
|
|
139
|
+
server = _bind_stored_port(stored)
|
|
140
|
+
if server is not None:
|
|
141
|
+
return server, stored, True
|
|
142
|
+
server = CallbackServer(port=port)
|
|
143
|
+
client_id = register_client(metadata, redirect_uris=[server.redirect_uri], client=http)
|
|
144
|
+
registration = OAuthClientRegistration(
|
|
145
|
+
client_id=client_id, redirect_uri=server.redirect_uri, issuer=metadata.issuer
|
|
146
|
+
)
|
|
147
|
+
save_oauth_client(registration)
|
|
148
|
+
return server, registration, False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _authorize(
|
|
152
|
+
metadata: AuthServerMetadata,
|
|
153
|
+
registration: OAuthClientRegistration,
|
|
154
|
+
server: CallbackServer,
|
|
155
|
+
*,
|
|
156
|
+
resource: str,
|
|
157
|
+
open_browser: bool,
|
|
158
|
+
http: httpx2.Client,
|
|
159
|
+
) -> OAuthCredential:
|
|
160
|
+
state = secrets.token_urlsafe(STATE_BYTES)
|
|
161
|
+
url, verifier = build_authorization_url(
|
|
162
|
+
metadata,
|
|
163
|
+
client_id=registration.client_id,
|
|
164
|
+
redirect_uri=registration.redirect_uri,
|
|
165
|
+
state=state,
|
|
166
|
+
resource=resource,
|
|
167
|
+
)
|
|
168
|
+
print(f"Open this URL in your browser to log in:\n{url}", file=sys.stderr)
|
|
169
|
+
if open_browser and not webbrowser.open(url):
|
|
170
|
+
print("Could not open a browser; open the URL above manually.", file=sys.stderr)
|
|
171
|
+
callback = server.wait(timeout=LOGIN_TIMEOUT_SECONDS)
|
|
172
|
+
if callback is None:
|
|
173
|
+
_abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login")
|
|
174
|
+
if callback.get("state") != state:
|
|
175
|
+
_abort_login("Invalid OAuth callback (state mismatch)")
|
|
176
|
+
if "error" in callback:
|
|
177
|
+
message = f"Authorization failed: {callback.get('error_description') or callback['error']}"
|
|
178
|
+
if callback["error"] in DEAD_CLIENT_ERRORS:
|
|
179
|
+
raise _DeadClientError(message)
|
|
180
|
+
_abort_login(message)
|
|
181
|
+
if "code" not in callback:
|
|
182
|
+
_abort_login("Invalid OAuth callback (missing code)")
|
|
183
|
+
try:
|
|
184
|
+
return exchange_code(
|
|
185
|
+
metadata,
|
|
186
|
+
client_id=registration.client_id,
|
|
187
|
+
code=callback["code"],
|
|
188
|
+
code_verifier=verifier,
|
|
189
|
+
redirect_uri=registration.redirect_uri,
|
|
190
|
+
resource=resource,
|
|
191
|
+
)
|
|
192
|
+
except OAuthError as exc:
|
|
193
|
+
if exc.error in DEAD_CLIENT_ERRORS:
|
|
194
|
+
raise _DeadClientError(f"Token exchange failed: {exc}") from exc
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _oauth_login(ctx: typer.Context, *, open_browser: bool, port: int) -> OAuthCredential:
|
|
199
|
+
base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/")
|
|
200
|
+
with httpx2.Client(timeout=OAUTH_HTTP_TIMEOUT_SECONDS) as http:
|
|
201
|
+
metadata = discover(base_url, client=http)
|
|
202
|
+
server, registration, reused = _register_or_reuse(metadata, port=port, http=http)
|
|
203
|
+
try:
|
|
204
|
+
with server:
|
|
205
|
+
return _authorize(
|
|
206
|
+
metadata, registration, server, resource=base_url, open_browser=open_browser, http=http
|
|
207
|
+
)
|
|
208
|
+
except _DeadClientError as exc:
|
|
209
|
+
if not reused:
|
|
210
|
+
_abort_login(str(exc))
|
|
211
|
+
delete_oauth_client()
|
|
212
|
+
server, registration, _ = _register_or_reuse(metadata, port=port, http=http)
|
|
213
|
+
try:
|
|
214
|
+
with server:
|
|
215
|
+
return _authorize(
|
|
216
|
+
metadata, registration, server, resource=base_url, open_browser=open_browser, http=http
|
|
217
|
+
)
|
|
218
|
+
except _DeadClientError as exc:
|
|
219
|
+
_abort_login(str(exc))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _api_key_login(ctx: typer.Context, *, api_key: str | None) -> None:
|
|
223
|
+
# An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may.
|
|
224
|
+
passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None
|
|
225
|
+
key = api_key or passed_globally or typer.prompt("API key", hide_input=True)
|
|
226
|
+
_verify(ctx, api_key=key)
|
|
227
|
+
save_config({"auth_method": AUTH_METHOD_API_KEY, "api_key": key})
|
|
228
|
+
print(json.dumps({"logged_in": True, "source": AUTH_METHOD_API_KEY}), file=sys.stderr)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
@handle_errors
|
|
233
|
+
def login(
|
|
234
|
+
ctx: typer.Context,
|
|
235
|
+
api_key: str | None = typer.Option(
|
|
236
|
+
None, help=f"Log in with an API key instead of the browser. Create one at {KEYS_URL}."
|
|
237
|
+
),
|
|
238
|
+
method: str = typer.Option(
|
|
239
|
+
AUTH_METHOD_OAUTH,
|
|
240
|
+
"--method",
|
|
241
|
+
help="oauth (browser login, default) or api_key (prompts for a key unless --api-key is given).",
|
|
242
|
+
),
|
|
243
|
+
no_browser: bool = typer.Option(False, "--no-browser", help="Print the login URL instead of opening a browser."),
|
|
244
|
+
port: int = typer.Option(
|
|
245
|
+
RANDOM_PORT, "--port", help="Fixed loopback port for the browser redirect (default: random; use with SSH)."
|
|
246
|
+
),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Log in via the browser (OAuth) or with an API key, verify, and save to the local config file."""
|
|
249
|
+
if method not in LOGIN_METHODS:
|
|
250
|
+
raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
|
|
251
|
+
global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION
|
|
252
|
+
if (
|
|
253
|
+
_is_interactive()
|
|
254
|
+
and api_key is None
|
|
255
|
+
and not global_key_passed
|
|
256
|
+
and not _was_passed_on_command_line(ctx, "method")
|
|
257
|
+
and not typer.confirm(HAS_ACCOUNT_PROMPT, default=True)
|
|
258
|
+
):
|
|
259
|
+
_offer_signup(ctx)
|
|
260
|
+
if api_key or global_key_passed or method == AUTH_METHOD_API_KEY:
|
|
261
|
+
_api_key_login(ctx, api_key=api_key)
|
|
262
|
+
return
|
|
263
|
+
credential = _oauth_login(ctx, open_browser=not no_browser, port=port)
|
|
264
|
+
_verify(ctx, auth=credential)
|
|
265
|
+
save_credential(credential)
|
|
266
|
+
print(
|
|
267
|
+
json.dumps({"logged_in": True, "method": AUTH_METHOD_OAUTH, "expires_at": _iso(credential.expires_at)}),
|
|
268
|
+
file=sys.stderr,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.command()
|
|
273
|
+
@handle_errors
|
|
274
|
+
def status(ctx: typer.Context) -> None:
|
|
275
|
+
"""Show which credential is in use (option, env, or config) and verify it against the API."""
|
|
276
|
+
key = ctx.obj.get("api_key")
|
|
277
|
+
if key:
|
|
278
|
+
_verify(ctx, api_key=str(key))
|
|
279
|
+
emit(
|
|
280
|
+
{
|
|
281
|
+
"source": _global_key_source(ctx),
|
|
282
|
+
"method": AUTH_METHOD_API_KEY,
|
|
283
|
+
"api_key": _mask(str(key)),
|
|
284
|
+
"valid": True,
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
return
|
|
288
|
+
credential = load_credential()
|
|
289
|
+
if credential is None:
|
|
290
|
+
raise AuthenticationError(NO_CREDENTIAL_MESSAGE)
|
|
291
|
+
if isinstance(credential, OAuthCredential):
|
|
292
|
+
_verify(ctx)
|
|
293
|
+
emit(
|
|
294
|
+
{
|
|
295
|
+
"source": SOURCE_CONFIG,
|
|
296
|
+
"method": AUTH_METHOD_OAUTH,
|
|
297
|
+
"expires_at": _iso(credential.expires_at),
|
|
298
|
+
"expired": credential.expires_within(0),
|
|
299
|
+
"valid": True,
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
_verify(ctx, api_key=credential.api_key)
|
|
304
|
+
emit({"source": SOURCE_CONFIG, "method": AUTH_METHOD_API_KEY, "api_key": _mask(credential.api_key), "valid": True})
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@app.command()
|
|
308
|
+
@handle_errors
|
|
309
|
+
def logout() -> None:
|
|
310
|
+
"""Delete saved credentials from the local config file (the registered OAuth client is kept)."""
|
|
311
|
+
delete_credential()
|
|
312
|
+
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
|
+
)
|