discolike-cli 0.3.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.3.0 → discolike_cli-0.3.1}/PKG-INFO +2 -2
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/pyproject.toml +2 -2
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/auth.py +32 -5
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/main.py +2 -0
- discolike_cli-0.3.1/src/discolike_cli/signup.py +76 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_auth_oauth.py +91 -5
- discolike_cli-0.3.1/tests/test_signup_cli.py +104 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/.gitignore +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/LICENSE +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/README.md +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/__init__.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/_loopback.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/_output.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/account.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/company.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/contacts.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/discogen.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/discover.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/email.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/enrich.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/match.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/providers.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/py.typed +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/src/discolike_cli/queries.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/conftest.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_account.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_auth.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_company.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_contacts_cli.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_discogen_cli.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_discover.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_email_cli.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_enrich_cli.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_match_cli.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_output.py +0 -0
- {discolike_cli-0.3.0 → discolike_cli-0.3.1}/tests/test_providers_cli.py +0 -0
- {discolike_cli-0.3.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.
|
|
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.3.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "discolike-cli"
|
|
7
|
-
version = "0.3.
|
|
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.3.
|
|
15
|
+
"discolike==0.3.1",
|
|
16
16
|
"typer>=0.12",
|
|
17
17
|
"rich>=13.0",
|
|
18
18
|
]
|
|
@@ -33,11 +33,12 @@ from discolike._oauth import OAuthError
|
|
|
33
33
|
from discolike._oauth import build_authorization_url
|
|
34
34
|
from discolike._oauth import discover
|
|
35
35
|
from discolike._oauth import exchange_code
|
|
36
|
-
from discolike._oauth import pkce_pair
|
|
37
36
|
from discolike._oauth import register_client
|
|
38
37
|
from discolike_cli._loopback import CallbackServer
|
|
39
38
|
from discolike_cli._output import emit
|
|
40
39
|
from discolike_cli._output import handle_errors
|
|
40
|
+
from discolike_cli.signup import _is_interactive
|
|
41
|
+
from discolike_cli.signup import run_signup
|
|
41
42
|
|
|
42
43
|
app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.")
|
|
43
44
|
|
|
@@ -55,6 +56,10 @@ LOGIN_METHODS = (AUTH_METHOD_OAUTH, AUTH_METHOD_API_KEY)
|
|
|
55
56
|
DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"})
|
|
56
57
|
|
|
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
|
+
|
|
58
63
|
class _DeadClientError(Exception):
|
|
59
64
|
"""The authorization server no longer recognises the registered client_id."""
|
|
60
65
|
|
|
@@ -63,6 +68,23 @@ def _mask(key: str) -> str:
|
|
|
63
68
|
return "…" + key[-MASKED_VISIBLE_CHARS:]
|
|
64
69
|
|
|
65
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
|
+
|
|
66
88
|
def _iso(epoch_seconds: float) -> str:
|
|
67
89
|
return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat()
|
|
68
90
|
|
|
@@ -135,13 +157,11 @@ def _authorize(
|
|
|
135
157
|
open_browser: bool,
|
|
136
158
|
http: httpx2.Client,
|
|
137
159
|
) -> OAuthCredential:
|
|
138
|
-
verifier, challenge = pkce_pair()
|
|
139
160
|
state = secrets.token_urlsafe(STATE_BYTES)
|
|
140
|
-
url = build_authorization_url(
|
|
161
|
+
url, verifier = build_authorization_url(
|
|
141
162
|
metadata,
|
|
142
163
|
client_id=registration.client_id,
|
|
143
164
|
redirect_uri=registration.redirect_uri,
|
|
144
|
-
code_challenge=challenge,
|
|
145
165
|
state=state,
|
|
146
166
|
resource=resource,
|
|
147
167
|
)
|
|
@@ -168,7 +188,6 @@ def _authorize(
|
|
|
168
188
|
code_verifier=verifier,
|
|
169
189
|
redirect_uri=registration.redirect_uri,
|
|
170
190
|
resource=resource,
|
|
171
|
-
client=http,
|
|
172
191
|
)
|
|
173
192
|
except OAuthError as exc:
|
|
174
193
|
if exc.error in DEAD_CLIENT_ERRORS:
|
|
@@ -230,6 +249,14 @@ def login(
|
|
|
230
249
|
if method not in LOGIN_METHODS:
|
|
231
250
|
raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method")
|
|
232
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)
|
|
233
260
|
if api_key or global_key_passed or method == AUTH_METHOD_API_KEY:
|
|
234
261
|
_api_key_login(ctx, api_key=api_key)
|
|
235
262
|
return
|
|
@@ -18,6 +18,7 @@ from discolike_cli import enrich
|
|
|
18
18
|
from discolike_cli import match
|
|
19
19
|
from discolike_cli import providers
|
|
20
20
|
from discolike_cli import queries
|
|
21
|
+
from discolike_cli import signup
|
|
21
22
|
|
|
22
23
|
app = typer.Typer(
|
|
23
24
|
name="discolike",
|
|
@@ -70,3 +71,4 @@ app.command(name="extract")(company.extract_command)
|
|
|
70
71
|
app.command(name="validate-icp")(enrich.validate_icp_command)
|
|
71
72
|
app.command(name="append")(enrich.append_command)
|
|
72
73
|
app.command(name="segment")(enrich.segment_command)
|
|
74
|
+
app.command(name="signup")(signup.signup_command)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from importlib.metadata import version as package_version
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from discolike._config import DEFAULT_BASE_URL
|
|
9
|
+
from discolike._config import load_signup_email
|
|
10
|
+
from discolike.signup import signup
|
|
11
|
+
from discolike_cli._output import emit
|
|
12
|
+
from discolike_cli._output import handle_errors
|
|
13
|
+
|
|
14
|
+
FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)."
|
|
15
|
+
YES_HELP = "Skip the confirmation when signing up a different email than last time."
|
|
16
|
+
CLI_AGENT = f"discolike-cli/{package_version('discolike-cli')}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_interactive() -> bool:
|
|
20
|
+
return sys.stdin.isatty()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _confirm_email_change(previous: str, email: str, *, yes: bool) -> bool:
|
|
24
|
+
if yes:
|
|
25
|
+
return True
|
|
26
|
+
prompt = f"This machine already signed up {previous}. Sign up {email} as well?"
|
|
27
|
+
if _is_interactive():
|
|
28
|
+
return typer.confirm(prompt, default=False)
|
|
29
|
+
typer.echo(f"{prompt} re-run with --yes", err=True)
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_signup(
|
|
34
|
+
*,
|
|
35
|
+
email: str,
|
|
36
|
+
first_name: str,
|
|
37
|
+
last_name: str,
|
|
38
|
+
agent: str | None,
|
|
39
|
+
base_url: str,
|
|
40
|
+
yes: bool,
|
|
41
|
+
fmt: str | None,
|
|
42
|
+
) -> None:
|
|
43
|
+
previous = load_signup_email()
|
|
44
|
+
allow_new_email = False
|
|
45
|
+
if previous is not None and previous.lower() != email.lower():
|
|
46
|
+
if not _confirm_email_change(previous, email, yes=yes):
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
allow_new_email = True
|
|
49
|
+
emit(
|
|
50
|
+
signup(
|
|
51
|
+
email=email,
|
|
52
|
+
first_name=first_name,
|
|
53
|
+
last_name=last_name,
|
|
54
|
+
agent=agent or CLI_AGENT,
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
allow_new_email=allow_new_email,
|
|
57
|
+
),
|
|
58
|
+
fmt=fmt,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@handle_errors
|
|
63
|
+
def signup_command(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
email: str = typer.Option(..., "--email", help="The person's work email. Becomes the login."),
|
|
66
|
+
first_name: str = typer.Option(..., "--first-name"),
|
|
67
|
+
last_name: str = typer.Option(..., "--last-name"),
|
|
68
|
+
agent: str | None = typer.Option(None, "--agent", help="Agent or framework name to record with the signup."),
|
|
69
|
+
yes: bool = typer.Option(False, "--yes", "-y", help=YES_HELP),
|
|
70
|
+
fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Create a DiscoLike account for a person. No login needed; they confirm by email."""
|
|
73
|
+
base_url = ctx.obj.get("base_url") or DEFAULT_BASE_URL
|
|
74
|
+
run_signup(
|
|
75
|
+
email=email, first_name=first_name, last_name=last_name, agent=agent, base_url=base_url, yes=yes, fmt=fmt
|
|
76
|
+
)
|
|
@@ -7,6 +7,7 @@ import threading
|
|
|
7
7
|
import time
|
|
8
8
|
from collections.abc import Callable
|
|
9
9
|
from typing import Any
|
|
10
|
+
from unittest.mock import patch
|
|
10
11
|
from urllib.parse import parse_qs
|
|
11
12
|
from urllib.parse import urlparse
|
|
12
13
|
from urllib.request import urlopen
|
|
@@ -75,18 +76,18 @@ class FakeProvider:
|
|
|
75
76
|
self.register_calls.append(redirect_uris)
|
|
76
77
|
return "client-1"
|
|
77
78
|
|
|
78
|
-
def exchange_code(self, metadata: AuthServerMetadata,
|
|
79
|
+
def exchange_code(self, metadata: AuthServerMetadata, **kwargs: Any) -> OAuthCredential:
|
|
79
80
|
self.exchange_calls.append(kwargs)
|
|
80
81
|
if self.exchange_failures:
|
|
81
82
|
raise self.exchange_failures.pop(0)
|
|
82
83
|
return CREDENTIAL
|
|
83
84
|
|
|
84
|
-
def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str:
|
|
85
|
-
url = self.real_build_authorization_url(metadata, **kwargs)
|
|
85
|
+
def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> tuple[str, str]:
|
|
86
|
+
url, verifier = self.real_build_authorization_url(metadata, **kwargs)
|
|
86
87
|
query = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()}
|
|
87
88
|
callback = f"{query['redirect_uri']}?{self.callback_query(query)}"
|
|
88
89
|
threading.Thread(target=lambda: urlopen(callback).read(), daemon=True).start() # noqa: S310 -- loopback test server
|
|
89
|
-
return url
|
|
90
|
+
return url, verifier
|
|
90
91
|
|
|
91
92
|
def open(self, url: str) -> bool:
|
|
92
93
|
self.opened_urls.append(url)
|
|
@@ -170,7 +171,9 @@ def test_login_timeout_exits_1(
|
|
|
170
171
|
) -> None:
|
|
171
172
|
install_build_client(_usage_ok)
|
|
172
173
|
monkeypatch.setattr(auth_module, "LOGIN_TIMEOUT_SECONDS", 0.2)
|
|
173
|
-
monkeypatch.setattr(
|
|
174
|
+
monkeypatch.setattr(
|
|
175
|
+
auth_module, "build_authorization_url", lambda metadata, **kwargs: ("https://auth.test/never", "v")
|
|
176
|
+
)
|
|
174
177
|
result = runner.invoke(app, ["auth", "login"])
|
|
175
178
|
assert result.exit_code == 1
|
|
176
179
|
assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"]
|
|
@@ -409,3 +412,86 @@ def test_login_forged_error_callback_cannot_evict_stored_client(
|
|
|
409
412
|
assert "state mismatch" in json.loads(result.stderr.splitlines()[-1])["message"]
|
|
410
413
|
assert provider.register_calls == []
|
|
411
414
|
assert load_oauth_client() == registration
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_login_tty_confirms_existing_account_runs_oauth(
|
|
418
|
+
provider: FakeProvider,
|
|
419
|
+
install_build_client: Callable[[Handler], None],
|
|
420
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
421
|
+
) -> None:
|
|
422
|
+
install_build_client(_usage_ok)
|
|
423
|
+
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
|
|
424
|
+
with patch("discolike_cli.auth.run_signup", autospec=True) as run_signup_mock:
|
|
425
|
+
result = runner.invoke(app, ["auth", "login"], input="y\n")
|
|
426
|
+
assert result.exit_code == 0, result.output
|
|
427
|
+
assert provider.discover_calls == [DEFAULT_BASE_URL]
|
|
428
|
+
run_signup_mock.assert_not_called()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_login_tty_declines_account_runs_signup(
|
|
432
|
+
provider: FakeProvider,
|
|
433
|
+
install_build_client: Callable[[Handler], None],
|
|
434
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
435
|
+
) -> None:
|
|
436
|
+
install_build_client(_usage_ok)
|
|
437
|
+
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
|
|
438
|
+
with patch("discolike_cli.auth.run_signup", autospec=True, return_value=None) as run_signup_mock:
|
|
439
|
+
result = runner.invoke(app, ["auth", "login"], input="n\njane@acme.com\nJane\nDoe\n")
|
|
440
|
+
assert result.exit_code == 0, result.output
|
|
441
|
+
run_signup_mock.assert_called_once_with(
|
|
442
|
+
email="jane@acme.com",
|
|
443
|
+
first_name="Jane",
|
|
444
|
+
last_name="Doe",
|
|
445
|
+
agent=None,
|
|
446
|
+
base_url=DEFAULT_BASE_URL,
|
|
447
|
+
yes=False,
|
|
448
|
+
fmt=None,
|
|
449
|
+
)
|
|
450
|
+
assert "run `discolike auth login` again" in result.output
|
|
451
|
+
assert provider.discover_calls == []
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def test_login_with_api_key_skips_account_question(install_build_client: Callable[[Handler], None]) -> None:
|
|
455
|
+
install_build_client(_usage_ok)
|
|
456
|
+
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
|
|
457
|
+
result = runner.invoke(app, ["auth", "login", "--api-key", "dk-1"])
|
|
458
|
+
assert result.exit_code == 0, result.output
|
|
459
|
+
confirm_mock.assert_not_called()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def test_login_with_global_api_key_skips_account_question(
|
|
463
|
+
install_build_client: Callable[[Handler], None],
|
|
464
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
465
|
+
) -> None:
|
|
466
|
+
install_build_client(_usage_ok)
|
|
467
|
+
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
|
|
468
|
+
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
|
|
469
|
+
result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login"])
|
|
470
|
+
assert result.exit_code == 0, result.output
|
|
471
|
+
confirm_mock.assert_not_called()
|
|
472
|
+
assert json.loads(config_path().read_text())["api_key"] == "dk-global"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_login_with_explicit_api_key_method_skips_account_question(
|
|
476
|
+
install_build_client: Callable[[Handler], None],
|
|
477
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
478
|
+
) -> None:
|
|
479
|
+
install_build_client(_usage_ok)
|
|
480
|
+
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
|
|
481
|
+
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
|
|
482
|
+
result = runner.invoke(app, ["auth", "login", "--method", "api_key", "--api-key", "dk-x"])
|
|
483
|
+
assert result.exit_code == 0, result.output
|
|
484
|
+
confirm_mock.assert_not_called()
|
|
485
|
+
assert json.loads(config_path().read_text())["api_key"] == "dk-x"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_login_rejects_unknown_method_on_a_tty_without_asking(
|
|
489
|
+
provider: FakeProvider,
|
|
490
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
491
|
+
) -> None:
|
|
492
|
+
monkeypatch.setattr(auth_module, "_is_interactive", lambda: True)
|
|
493
|
+
with patch("discolike_cli.auth.typer.confirm", autospec=True) as confirm_mock:
|
|
494
|
+
result = runner.invoke(app, ["auth", "login", "--method", "bogus"])
|
|
495
|
+
assert result.exit_code == 2
|
|
496
|
+
confirm_mock.assert_not_called()
|
|
497
|
+
assert provider.discover_calls == []
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from discolike._config import save_signup_email
|
|
9
|
+
from discolike.signup import SignupResult
|
|
10
|
+
from discolike_cli.main import app
|
|
11
|
+
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
|
|
14
|
+
_RESULT = SignupResult(
|
|
15
|
+
status="created",
|
|
16
|
+
email="jane@acme.com",
|
|
17
|
+
org_domain="acme.com",
|
|
18
|
+
org_status="created",
|
|
19
|
+
next_step="A confirmation email was sent to jane@acme.com.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_signup_calls_sdk_without_client() -> None:
|
|
24
|
+
with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
|
|
25
|
+
result = runner.invoke(
|
|
26
|
+
app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane", "--last-name", "Doe"]
|
|
27
|
+
)
|
|
28
|
+
assert result.exit_code == 0, result.output
|
|
29
|
+
sdk_signup.assert_called_once()
|
|
30
|
+
kwargs = sdk_signup.call_args.kwargs
|
|
31
|
+
assert kwargs["email"] == "jane@acme.com"
|
|
32
|
+
assert kwargs["agent"].startswith("discolike-cli/")
|
|
33
|
+
assert json.loads(result.stdout)["next_step"] == _RESULT.next_step
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_signup_honours_base_url_option() -> None:
|
|
37
|
+
with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
|
|
38
|
+
result = runner.invoke(
|
|
39
|
+
app,
|
|
40
|
+
[
|
|
41
|
+
"--base-url",
|
|
42
|
+
"https://api.dev.test/v1",
|
|
43
|
+
"signup",
|
|
44
|
+
"--email",
|
|
45
|
+
"j@acme.com",
|
|
46
|
+
"--first-name",
|
|
47
|
+
"J",
|
|
48
|
+
"--last-name",
|
|
49
|
+
"D",
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
assert result.exit_code == 0, result.output
|
|
53
|
+
assert sdk_signup.call_args.kwargs["base_url"] == "https://api.dev.test/v1"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_signup_does_not_require_credentials(monkeypatch) -> None:
|
|
57
|
+
monkeypatch.delenv("DISCOLIKE_API_KEY", raising=False)
|
|
58
|
+
with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT):
|
|
59
|
+
result = runner.invoke(app, ["signup", "--email", "j@acme.com", "--first-name", "J", "--last-name", "D"])
|
|
60
|
+
assert result.exit_code == 0, result.output
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_signup_different_email_non_tty_exits_without_calling_sdk() -> None:
|
|
64
|
+
save_signup_email("jane@acme.com")
|
|
65
|
+
with patch("discolike_cli.signup.signup", autospec=True, return_value=_RESULT) as sdk_signup:
|
|
66
|
+
result = runner.invoke(
|
|
67
|
+
app, ["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person"]
|
|
68
|
+
)
|
|
69
|
+
assert result.exit_code == 1
|
|
70
|
+
sdk_signup.assert_not_called()
|
|
71
|
+
assert "re-run with --yes" in result.output
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_signup_different_email_with_yes_allows_new_email() -> None:
|
|
75
|
+
save_signup_email("jane@acme.com")
|
|
76
|
+
other_result = SignupResult(**{**_RESULT.to_dict(), "email": "other@acme.com"})
|
|
77
|
+
with patch("discolike_cli.signup.signup", autospec=True, return_value=other_result) as sdk_signup:
|
|
78
|
+
result = runner.invoke(
|
|
79
|
+
app,
|
|
80
|
+
["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person", "--yes"],
|
|
81
|
+
)
|
|
82
|
+
assert result.exit_code == 0, result.output
|
|
83
|
+
assert sdk_signup.call_args.kwargs["allow_new_email"] is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_signup_different_email_tty_confirm_proceeds(monkeypatch) -> None:
|
|
87
|
+
save_signup_email("jane@acme.com")
|
|
88
|
+
monkeypatch.setattr("discolike_cli.signup._is_interactive", lambda: True)
|
|
89
|
+
other_result = SignupResult(**{**_RESULT.to_dict(), "email": "other@acme.com"})
|
|
90
|
+
with patch("discolike_cli.signup.signup", autospec=True, return_value=other_result) as sdk_signup:
|
|
91
|
+
result = runner.invoke(
|
|
92
|
+
app,
|
|
93
|
+
["signup", "--email", "other@acme.com", "--first-name", "Other", "--last-name", "Person"],
|
|
94
|
+
input="y\n",
|
|
95
|
+
)
|
|
96
|
+
assert result.exit_code == 0, result.output
|
|
97
|
+
assert sdk_signup.call_args.kwargs["allow_new_email"] is True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_signup_rejected_name_exits_nonzero_with_message_on_stderr() -> None:
|
|
101
|
+
result = runner.invoke(app, ["signup", "--email", "jane@acme.com", "--first-name", "Jane<b>", "--last-name", "Doe"])
|
|
102
|
+
assert result.exit_code != 0
|
|
103
|
+
payload = json.loads(result.stderr)
|
|
104
|
+
assert "must contain a letter and no angle brackets or control characters" in payload["message"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|