gmcli 0.1.0__tar.gz → 0.1.2__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.
- {gmcli-0.1.0 → gmcli-0.1.2}/CLAUDE.md +18 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/PKG-INFO +16 -3
- {gmcli-0.1.0 → gmcli-0.1.2}/README.md +15 -2
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/__init__.py +1 -1
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/flow.py +111 -4
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/cli.py +51 -2
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/auth.py +44 -2
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/setup.py +35 -6
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/config.py +25 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/app.py +72 -18
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/conftest.py +5 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_client_config.py +124 -1
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_commands.py +144 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_ui.py +74 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/.github/workflows/release.yml +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/.gitignore +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/LICENSE +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/pyproject.toml +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/__main__.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/__init__.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/attachments.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/client.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/compose.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/labels.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/messages.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/api/threads.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/__init__.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/client_config.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/auth/store.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/bodytext.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/cache.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/__init__.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/attachments.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/cache_cmd.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/draft.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/labels.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/listing.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/modify.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/read.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/send.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/commands/ui_cmd.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/context.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/errors.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/idref.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/models.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/output.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/__init__.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/graphics.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/keys.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/render.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/ui/state.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/src/gmcli/update.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_attachments.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_bodytext.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_compose.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_documented_usage.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_idref.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_models.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_output.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_store.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/tests/test_update.py +0 -0
- {gmcli-0.1.0 → gmcli-0.1.2}/uv.lock +0 -0
|
@@ -160,6 +160,16 @@ Consequences when editing `cli.py`:
|
|
|
160
160
|
near the bottom, or its commands will not be guarded.
|
|
161
161
|
- To signal a failure, `raise` the appropriate `GmcliError` subclass with a
|
|
162
162
|
`hint=` — do not call `sys.exit` or print-and-return.
|
|
163
|
+
- An **option callback** (`--version`, `--upgrade`, `--account`) runs *outside*
|
|
164
|
+
those wrappers, so it must `_report` and `raise typer.Exit(code=…)` itself.
|
|
165
|
+
|
|
166
|
+
The root callback is `invoke_without_command=True`, so global options with no
|
|
167
|
+
command reach `_no_command()` instead of stopping at Click's bare "Missing
|
|
168
|
+
command.": it prints the same help bare `gmail` does and exits 2 — except
|
|
169
|
+
under `--json`, where the help would land on the pipe, so the error goes to
|
|
170
|
+
stderr instead. `_account_callback` rejects an `--account` value with no `@`
|
|
171
|
+
in it, because the parser hands the option whatever token follows and
|
|
172
|
+
`gmail --account -A` otherwise made `-A` the mailbox name.
|
|
163
173
|
|
|
164
174
|
### Two command registration styles
|
|
165
175
|
|
|
@@ -178,6 +188,14 @@ called at the *bottom* of `commands/auth.py` (`setup.py` imports nothing from
|
|
|
178
188
|
`auth.py`, so there is no cycle). It also reorders itself to the front of the
|
|
179
189
|
group's help, since it is where a new user starts.
|
|
180
190
|
|
|
191
|
+
`commands/auth.py` uses *both* styles: its `app` group is added with
|
|
192
|
+
`add_typer`, and its `register(root)` additionally attaches `gmail login` and
|
|
193
|
+
`gmail logout` to the root app — the same two callbacks, not copies, so the
|
|
194
|
+
short spellings cannot drift from `gmail auth login`/`logout`. Each
|
|
195
|
+
registration gets its own `_guard` wrapper, which is all `cli.py` needs.
|
|
196
|
+
`test_the_two_spellings_of_login_and_logout_cannot_drift` compares the two
|
|
197
|
+
parameter lists.
|
|
198
|
+
|
|
181
199
|
### The `ui/` package
|
|
182
200
|
|
|
183
201
|
Five modules, and the split is load-bearing:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gmcli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Manage Gmail from the terminal: read, search, send, and organize mail over the Gmail API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/snehangshu2002/gmcli
|
|
6
6
|
Project-URL: Issues, https://github.com/snehangshu2002/gmcli/issues
|
|
@@ -190,9 +190,17 @@ Then hand it over:
|
|
|
190
190
|
$ gmail auth login --credentials ~/Downloads/client_secret_1234.json
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
gmcli
|
|
193
|
+
gmcli installs the file into its own data directory at mode `0600`, so later
|
|
194
194
|
logins need no flag.
|
|
195
195
|
|
|
196
|
+
A file that came out of `~/Downloads` or `~/Desktop` is **moved** rather than
|
|
197
|
+
copied — the browser wrote a working OAuth client into a directory that syncs,
|
|
198
|
+
backs up, and gets shared out of, and gmcli will not leave a second copy of it
|
|
199
|
+
there. The destination is written first and the original removed only once that
|
|
200
|
+
succeeded, so there is always at least one readable copy. Pass
|
|
201
|
+
`--keep-download` to leave the file where it is; a client anywhere else is
|
|
202
|
+
copied and never touched.
|
|
203
|
+
|
|
196
204
|
</details>
|
|
197
205
|
|
|
198
206
|
Check it worked:
|
|
@@ -568,12 +576,17 @@ Exit codes:
|
|
|
568
576
|
|
|
569
577
|
```console
|
|
570
578
|
$ gmail auth setup # once, first account
|
|
571
|
-
$ gmail
|
|
579
|
+
$ gmail login # repeat per account
|
|
572
580
|
$ gmail auth list
|
|
573
581
|
$ gmail auth switch work@example.com # set the default
|
|
574
582
|
$ gmail --account personal@gmail.com ls # override per command
|
|
583
|
+
$ gmail logout # forget this machine's copy
|
|
584
|
+
$ gmail logout --all # every account
|
|
575
585
|
```
|
|
576
586
|
|
|
587
|
+
`gmail login` and `gmail logout` are the same commands as `gmail auth login`
|
|
588
|
+
and `gmail auth logout`, spelled where you would first look for them.
|
|
589
|
+
|
|
577
590
|
## Configuration
|
|
578
591
|
|
|
579
592
|
`~/.config/gmcli/config.toml` (XDG-aware):
|
|
@@ -153,9 +153,17 @@ Then hand it over:
|
|
|
153
153
|
$ gmail auth login --credentials ~/Downloads/client_secret_1234.json
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
gmcli
|
|
156
|
+
gmcli installs the file into its own data directory at mode `0600`, so later
|
|
157
157
|
logins need no flag.
|
|
158
158
|
|
|
159
|
+
A file that came out of `~/Downloads` or `~/Desktop` is **moved** rather than
|
|
160
|
+
copied — the browser wrote a working OAuth client into a directory that syncs,
|
|
161
|
+
backs up, and gets shared out of, and gmcli will not leave a second copy of it
|
|
162
|
+
there. The destination is written first and the original removed only once that
|
|
163
|
+
succeeded, so there is always at least one readable copy. Pass
|
|
164
|
+
`--keep-download` to leave the file where it is; a client anywhere else is
|
|
165
|
+
copied and never touched.
|
|
166
|
+
|
|
159
167
|
</details>
|
|
160
168
|
|
|
161
169
|
Check it worked:
|
|
@@ -531,12 +539,17 @@ Exit codes:
|
|
|
531
539
|
|
|
532
540
|
```console
|
|
533
541
|
$ gmail auth setup # once, first account
|
|
534
|
-
$ gmail
|
|
542
|
+
$ gmail login # repeat per account
|
|
535
543
|
$ gmail auth list
|
|
536
544
|
$ gmail auth switch work@example.com # set the default
|
|
537
545
|
$ gmail --account personal@gmail.com ls # override per command
|
|
546
|
+
$ gmail logout # forget this machine's copy
|
|
547
|
+
$ gmail logout --all # every account
|
|
538
548
|
```
|
|
539
549
|
|
|
550
|
+
`gmail login` and `gmail logout` are the same commands as `gmail auth login`
|
|
551
|
+
and `gmail auth logout`, spelled where you would first look for them.
|
|
552
|
+
|
|
540
553
|
## Configuration
|
|
541
554
|
|
|
542
555
|
`~/.config/gmcli/config.toml` (XDG-aware):
|
|
@@ -14,10 +14,16 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
16
|
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any
|
|
19
20
|
|
|
20
|
-
from ..config import
|
|
21
|
+
from ..config import (
|
|
22
|
+
DOWNLOAD_DIRS,
|
|
23
|
+
client_secret_path,
|
|
24
|
+
is_in_download_dir,
|
|
25
|
+
write_secret_file,
|
|
26
|
+
)
|
|
21
27
|
from ..errors import AuthError
|
|
22
28
|
from .client_config import ClientConfig, parse_client_file, resolve_client
|
|
23
29
|
from .store import TokenStore, get_store, register_account
|
|
@@ -37,12 +43,37 @@ PUBLISHING_STATUS_HINT = (
|
|
|
37
43
|
)
|
|
38
44
|
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Installed:
|
|
48
|
+
"""Where the client went, and what happened to the file it came from."""
|
|
49
|
+
|
|
50
|
+
path: Path
|
|
51
|
+
source: Path
|
|
52
|
+
moved: bool = False
|
|
53
|
+
#: Set when the source was in a download directory but could not be removed.
|
|
54
|
+
left_behind: str | None = None
|
|
55
|
+
|
|
56
|
+
def __fspath__(self) -> str:
|
|
57
|
+
"""So an ``Installed`` is still usable anywhere the path was."""
|
|
58
|
+
return str(self.path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def install_client_secret(source: Path, *, keep_source: bool = False) -> Installed:
|
|
62
|
+
"""Install the user's OAuth client into our data dir, at ``0600``.
|
|
42
63
|
|
|
43
64
|
Validation goes through the same parser the resolver uses, so a web client
|
|
44
65
|
or a service-account key is rejected here with the same explanation rather
|
|
45
66
|
than failing later inside the consent flow.
|
|
67
|
+
|
|
68
|
+
A client that came out of a download directory is *moved* rather than
|
|
69
|
+
copied: the browser wrote it world-readable into a directory that syncs,
|
|
70
|
+
backs up, and gets shared out of, and a copy left there is a live
|
|
71
|
+
credential nobody is looking after. It is safe to lose because it is not
|
|
72
|
+
lost — the destination is written and fsynced-into-place first, and the
|
|
73
|
+
source is only unlinked once that has succeeded, so a failure at any point
|
|
74
|
+
leaves at least one readable copy. Anywhere else the file is left exactly
|
|
75
|
+
where it is: a path the user typed or keeps in a repo is a path they
|
|
76
|
+
chose, and deleting it would be a surprise rather than a tidy-up.
|
|
46
77
|
"""
|
|
47
78
|
source = source.expanduser()
|
|
48
79
|
if not source.exists():
|
|
@@ -59,7 +90,80 @@ def install_client_secret(source: Path) -> Path:
|
|
|
59
90
|
|
|
60
91
|
dest = client_secret_path()
|
|
61
92
|
write_secret_file(dest, json.dumps(payload, indent=2))
|
|
62
|
-
|
|
93
|
+
|
|
94
|
+
if keep_source or not is_in_download_dir(source) or source.resolve() == dest:
|
|
95
|
+
return Installed(path=dest, source=source)
|
|
96
|
+
try:
|
|
97
|
+
source.unlink()
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
# Not fatal: the client is installed and works. Say so, because the
|
|
100
|
+
# whole point was that the copy in Downloads should stop existing.
|
|
101
|
+
return Installed(path=dest, source=source, left_behind=str(exc))
|
|
102
|
+
return Installed(path=dest, source=source, moved=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def install_notes(installed: "Installed", client_id: str) -> list[tuple[str, str]]:
|
|
106
|
+
"""What a front end should say after an install, as ``(level, message)``.
|
|
107
|
+
|
|
108
|
+
The text lives here rather than in the commands because both ``auth login``
|
|
109
|
+
and ``auth setup`` install a client and must say the same thing about it;
|
|
110
|
+
the levels keep ``output.py`` in charge of how it looks.
|
|
111
|
+
"""
|
|
112
|
+
notes: list[tuple[str, str]] = []
|
|
113
|
+
if installed.moved:
|
|
114
|
+
notes.append(
|
|
115
|
+
(
|
|
116
|
+
"info",
|
|
117
|
+
f"Moved {installed.source} out of your downloads — "
|
|
118
|
+
f"it now lives only at {installed.path} (0600).",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
elif installed.left_behind:
|
|
122
|
+
notes.append(
|
|
123
|
+
(
|
|
124
|
+
"warn",
|
|
125
|
+
f"Could not remove {installed.source} ({installed.left_behind}). "
|
|
126
|
+
"It is a working OAuth client — delete it yourself.",
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
leftovers = [
|
|
131
|
+
p for p in other_downloaded_copies(client_id) if p != installed.source
|
|
132
|
+
]
|
|
133
|
+
if leftovers:
|
|
134
|
+
listed = ", ".join(str(p) for p in leftovers)
|
|
135
|
+
notes.append(
|
|
136
|
+
(
|
|
137
|
+
"warn",
|
|
138
|
+
f"Another copy of this same client is still in your downloads: "
|
|
139
|
+
f"{listed}. gmcli will not delete a file it was not given.",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return notes
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def other_downloaded_copies(client_id: str) -> list[Path]:
|
|
146
|
+
"""Client files in the download directories carrying this same client id.
|
|
147
|
+
|
|
148
|
+
Re-downloading gives you ``client_secret_… (1).json`` next to the original,
|
|
149
|
+
and moving one of them out solves nothing while its twin is still there.
|
|
150
|
+
These are only reported, never removed: gmcli takes responsibility for the
|
|
151
|
+
file it was handed, not for tidying a directory it does not own.
|
|
152
|
+
"""
|
|
153
|
+
found: list[Path] = []
|
|
154
|
+
for name in DOWNLOAD_DIRS:
|
|
155
|
+
base = Path(name).expanduser()
|
|
156
|
+
if not base.is_dir():
|
|
157
|
+
continue
|
|
158
|
+
for path in sorted(base.glob("client_secret*.json")):
|
|
159
|
+
try:
|
|
160
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
161
|
+
except (OSError, json.JSONDecodeError):
|
|
162
|
+
continue
|
|
163
|
+
block = payload.get("installed")
|
|
164
|
+
if isinstance(block, dict) and block.get("client_id") == client_id:
|
|
165
|
+
found.append(path)
|
|
166
|
+
return found
|
|
63
167
|
|
|
64
168
|
|
|
65
169
|
def _serialize(creds: Any, account: str) -> dict[str, Any]:
|
|
@@ -226,10 +330,13 @@ __all__ = [
|
|
|
226
330
|
"PUBLISHING_STATUS_HINT",
|
|
227
331
|
"SCOPES",
|
|
228
332
|
"TESTING_TOKEN_LIFETIME_DAYS",
|
|
333
|
+
"Installed",
|
|
229
334
|
"fetch_account_email",
|
|
230
335
|
"install_client_secret",
|
|
336
|
+
"install_notes",
|
|
231
337
|
"load_credentials",
|
|
232
338
|
"login",
|
|
233
339
|
"logout",
|
|
340
|
+
"other_downloaded_copies",
|
|
234
341
|
"token_age_days",
|
|
235
342
|
]
|
|
@@ -19,7 +19,7 @@ from .commands import listing, modify, read, send
|
|
|
19
19
|
from .commands import ui_cmd
|
|
20
20
|
from .config import Config
|
|
21
21
|
from .context import AppContext
|
|
22
|
-
from .errors import GmcliError
|
|
22
|
+
from .errors import GmcliError, UsageError
|
|
23
23
|
|
|
24
24
|
app = typer.Typer(
|
|
25
25
|
name="gmail",
|
|
@@ -45,6 +45,7 @@ app.add_typer(draft_cmd.app, name="draft")
|
|
|
45
45
|
app.add_typer(attachments_cmd.app, name="attachments")
|
|
46
46
|
app.add_typer(cache_cmd.app, name="cache")
|
|
47
47
|
|
|
48
|
+
auth_cmd.register(app)
|
|
48
49
|
listing.register(app)
|
|
49
50
|
read.register(app)
|
|
50
51
|
modify.register(app)
|
|
@@ -105,6 +106,50 @@ def _version_callback(value: bool) -> None:
|
|
|
105
106
|
raise typer.Exit()
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
def _account_callback(value: str | None) -> str | None:
|
|
110
|
+
"""Reject an ``--account`` value that cannot be an address.
|
|
111
|
+
|
|
112
|
+
``gmail --account -A`` is the shape that motivates this: the parser hands
|
|
113
|
+
the option whatever token comes next, so a mistyped flag became the
|
|
114
|
+
mailbox name and the mistake surfaced much later — as a missing-credential
|
|
115
|
+
error naming ``-A``, or not at all on a command that never opens the
|
|
116
|
+
account. An option callback runs outside the wrappers installed below, so
|
|
117
|
+
it maps itself onto the documented usage exit code.
|
|
118
|
+
"""
|
|
119
|
+
if value is None:
|
|
120
|
+
return None
|
|
121
|
+
if "@" not in value:
|
|
122
|
+
exc = UsageError(
|
|
123
|
+
f"--account expects an email address, not {value!r}.",
|
|
124
|
+
hint="`gmail auth list` shows the accounts you are logged in to.",
|
|
125
|
+
)
|
|
126
|
+
_report(exc)
|
|
127
|
+
raise typer.Exit(code=exc.exit_code)
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _no_command(ctx: typer.Context, json_mode: bool) -> None:
|
|
132
|
+
"""Handle global options given with no command to run.
|
|
133
|
+
|
|
134
|
+
``gmail --json`` used to stop at a bare "Missing command.", which names
|
|
135
|
+
the problem and nothing else. Bare ``gmail`` already answers it by
|
|
136
|
+
printing the help, so do the same here — the flags are the front half of a
|
|
137
|
+
command the user has not finished typing. Under ``--json`` the help would
|
|
138
|
+
land on the pipe, so that case gets the error on stderr instead.
|
|
139
|
+
"""
|
|
140
|
+
if json_mode:
|
|
141
|
+
_report(
|
|
142
|
+
UsageError(
|
|
143
|
+
"No command given.",
|
|
144
|
+
hint="Run `gmail --help` to see the commands.",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
# Typer renders the help itself and returns an empty string.
|
|
149
|
+
ctx.get_help()
|
|
150
|
+
raise typer.Exit(code=2)
|
|
151
|
+
|
|
152
|
+
|
|
108
153
|
def _upgrade_callback(value: bool) -> None:
|
|
109
154
|
"""``gmail --upgrade``: fetch the current release and install it.
|
|
110
155
|
|
|
@@ -151,7 +196,7 @@ def _announce_update(ctx: typer.Context) -> None:
|
|
|
151
196
|
update.start_check(app_ctx.config)
|
|
152
197
|
|
|
153
198
|
|
|
154
|
-
@app.callback()
|
|
199
|
+
@app.callback(invoke_without_command=True)
|
|
155
200
|
def main_callback(
|
|
156
201
|
ctx: typer.Context,
|
|
157
202
|
account: str = typer.Option(
|
|
@@ -160,6 +205,7 @@ def main_callback(
|
|
|
160
205
|
"-A",
|
|
161
206
|
help="Act on this account instead of the default.",
|
|
162
207
|
metavar="EMAIL",
|
|
208
|
+
callback=_account_callback,
|
|
163
209
|
),
|
|
164
210
|
json_output: bool = typer.Option(
|
|
165
211
|
False,
|
|
@@ -189,6 +235,9 @@ def main_callback(
|
|
|
189
235
|
),
|
|
190
236
|
) -> None:
|
|
191
237
|
"""Manage Gmail from the terminal."""
|
|
238
|
+
if ctx.invoked_subcommand is None:
|
|
239
|
+
_no_command(ctx, json_output)
|
|
240
|
+
|
|
192
241
|
config = Config.load()
|
|
193
242
|
ctx.obj = AppContext(
|
|
194
243
|
account_override=account,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""``gmail auth …`` — login, logout, account switching, and diagnostics.
|
|
1
|
+
"""``gmail auth …`` — login, logout, account switching, and diagnostics.
|
|
2
|
+
|
|
3
|
+
``gmail login`` and ``gmail logout`` are the same two commands attached to the
|
|
4
|
+
root app as well, by :func:`register`: getting in and out is the pair of verbs
|
|
5
|
+
a user reaches for before they know the group exists, and it costs nothing to
|
|
6
|
+
answer where they look for it.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from __future__ import annotations
|
|
4
10
|
|
|
@@ -11,6 +17,8 @@ from ..auth.flow import (
|
|
|
11
17
|
PUBLISHING_STATUS_HINT,
|
|
12
18
|
SCOPES,
|
|
13
19
|
TESTING_TOKEN_LIFETIME_DAYS,
|
|
20
|
+
install_client_secret,
|
|
21
|
+
install_notes,
|
|
14
22
|
load_credentials,
|
|
15
23
|
login,
|
|
16
24
|
logout,
|
|
@@ -54,6 +62,12 @@ def login_cmd(
|
|
|
54
62
|
help="Fix the loopback redirect port (useful over an SSH tunnel). "
|
|
55
63
|
"0 picks a free one.",
|
|
56
64
|
),
|
|
65
|
+
keep_download: bool = typer.Option(
|
|
66
|
+
False,
|
|
67
|
+
"--keep-download",
|
|
68
|
+
help="Leave the credentials file where it is. By default a client "
|
|
69
|
+
"found in ~/Downloads or ~/Desktop is moved, not copied.",
|
|
70
|
+
),
|
|
57
71
|
) -> None:
|
|
58
72
|
"""Authorize an account through your browser."""
|
|
59
73
|
app_ctx: AppContext = ctx.obj
|
|
@@ -63,9 +77,15 @@ def login_cmd(
|
|
|
63
77
|
# like a crash rather than a missing prerequisite.
|
|
64
78
|
resolve_client(credentials, config=app_ctx.config)
|
|
65
79
|
|
|
80
|
+
# Install here rather than inside `login`, so the file is safe before the
|
|
81
|
+
# browser opens and this command has something to report about it.
|
|
82
|
+
installed = None
|
|
83
|
+
if credentials is not None:
|
|
84
|
+
installed = install_client_secret(credentials, keep_source=keep_download)
|
|
85
|
+
|
|
66
86
|
out.info("Opening your browser to authorize gmcli…")
|
|
67
87
|
out.info(f"[dim]Requesting scope: {SCOPES[0]}[/dim]")
|
|
68
|
-
email, store, client = login(
|
|
88
|
+
email, store, client = login(port=port)
|
|
69
89
|
|
|
70
90
|
if app_ctx.json_mode:
|
|
71
91
|
out.json(
|
|
@@ -80,6 +100,9 @@ def login_cmd(
|
|
|
80
100
|
|
|
81
101
|
out.success(f"Authorized as [bold]{email}[/bold]")
|
|
82
102
|
out.info(f"[dim]Credentials stored in {store.description}[/dim]")
|
|
103
|
+
if installed is not None:
|
|
104
|
+
for level, message in install_notes(installed, client.client_id):
|
|
105
|
+
(out.warn if level == "warn" else out.info)(message)
|
|
83
106
|
|
|
84
107
|
if not app_ctx.config.default_account:
|
|
85
108
|
app_ctx.config.default_account = email
|
|
@@ -400,6 +423,25 @@ def _clock_skew_seconds() -> float | None:
|
|
|
400
423
|
return None
|
|
401
424
|
|
|
402
425
|
|
|
426
|
+
def register(root: typer.Typer) -> None:
|
|
427
|
+
"""Attach ``gmail login`` / ``gmail logout`` to the root app.
|
|
428
|
+
|
|
429
|
+
The same callbacks, not copies, so the two spellings can never drift in
|
|
430
|
+
flags or behaviour — each registration carries its own error-handling
|
|
431
|
+
wrapper, which is all ``cli.py`` needs.
|
|
432
|
+
"""
|
|
433
|
+
root.command(
|
|
434
|
+
"login",
|
|
435
|
+
help="Authorize an account through your browser. "
|
|
436
|
+
"Same as [bold]gmail auth login[/bold].",
|
|
437
|
+
)(login_cmd)
|
|
438
|
+
root.command(
|
|
439
|
+
"logout",
|
|
440
|
+
help="Remove stored credentials from this machine. "
|
|
441
|
+
"Same as [bold]gmail auth logout[/bold].",
|
|
442
|
+
)(logout_cmd)
|
|
443
|
+
|
|
444
|
+
|
|
403
445
|
# Registered here rather than in cli.py so the wizard travels with the group it
|
|
404
446
|
# belongs to. setup.py imports nothing from this module, so there is no cycle.
|
|
405
447
|
from .setup import register as _register_setup # noqa: E402
|
|
@@ -25,16 +25,19 @@ from pathlib import Path
|
|
|
25
25
|
import typer
|
|
26
26
|
|
|
27
27
|
from ..auth.client_config import load_client_file
|
|
28
|
-
from ..auth.flow import install_client_secret, login
|
|
29
|
-
from ..config import
|
|
28
|
+
from ..auth.flow import install_client_secret, install_notes, login
|
|
29
|
+
from ..config import DOWNLOAD_DIRS as BROWSER_DOWNLOAD_DIRS
|
|
30
|
+
from ..config import client_secret_path, is_in_download_dir
|
|
30
31
|
from ..context import AppContext
|
|
31
32
|
from ..errors import AuthError, UsageError
|
|
32
33
|
from ..output import Renderer
|
|
33
34
|
|
|
34
35
|
CONSOLE = "https://console.cloud.google.com"
|
|
35
36
|
|
|
36
|
-
# Where browsers land a downloaded client, newest first wins.
|
|
37
|
-
|
|
37
|
+
# Where browsers land a downloaded client, newest first wins. The current
|
|
38
|
+
# directory is searched but is not one of ``config.DOWNLOAD_DIRS``: a file
|
|
39
|
+
# someone ran the command next to is not in transit, so it is never moved.
|
|
40
|
+
DOWNLOAD_DIRS = (*BROWSER_DOWNLOAD_DIRS, ".")
|
|
38
41
|
DOWNLOAD_GLOBS = ("client_secret*.json", "client_secret*.JSON")
|
|
39
42
|
# A file touched within this window is almost certainly the one just downloaded.
|
|
40
43
|
FRESH_SECONDS = 1800
|
|
@@ -129,6 +132,12 @@ def _collect_client_file(out: Renderer) -> Path:
|
|
|
129
132
|
if candidates:
|
|
130
133
|
best = candidates[0]
|
|
131
134
|
out.info(f"\n Found [green]{best}[/green]")
|
|
135
|
+
if is_in_download_dir(best):
|
|
136
|
+
out.info(
|
|
137
|
+
" [dim]It will be moved into gmcli's data directory, not "
|
|
138
|
+
"copied — an OAuth client should not be left in a download "
|
|
139
|
+
"folder. Re-run with --keep-download to leave it.[/dim]"
|
|
140
|
+
)
|
|
132
141
|
if typer.confirm(" Use this file?", default=True):
|
|
133
142
|
return best
|
|
134
143
|
|
|
@@ -179,6 +188,12 @@ def register(auth_app: typer.Typer) -> None:
|
|
|
179
188
|
force: bool = typer.Option(
|
|
180
189
|
False, "--force", "-f", help="Replace an already-installed client."
|
|
181
190
|
),
|
|
191
|
+
keep_download: bool = typer.Option(
|
|
192
|
+
False,
|
|
193
|
+
"--keep-download",
|
|
194
|
+
help="Leave the downloaded client JSON in place. By default it is "
|
|
195
|
+
"moved out of ~/Downloads rather than copied.",
|
|
196
|
+
),
|
|
182
197
|
) -> None:
|
|
183
198
|
"""Set up Google sign-in, step by step.
|
|
184
199
|
|
|
@@ -191,8 +206,9 @@ def register(auth_app: typer.Typer) -> None:
|
|
|
191
206
|
|
|
192
207
|
# The fast path: a file was handed to us, so no walkthrough is needed.
|
|
193
208
|
if credentials is not None:
|
|
194
|
-
install_client_secret(credentials)
|
|
209
|
+
installed = install_client_secret(credentials, keep_source=keep_download)
|
|
195
210
|
out.success(f"Installed OAuth client from {credentials}")
|
|
211
|
+
_report_install(out, installed)
|
|
196
212
|
_finish_with_login(app_ctx, no_browser=no_browser)
|
|
197
213
|
return
|
|
198
214
|
|
|
@@ -226,9 +242,10 @@ def register(auth_app: typer.Typer) -> None:
|
|
|
226
242
|
_step_create_client(out, project, total, no_browser)
|
|
227
243
|
|
|
228
244
|
path = _collect_client_file(out)
|
|
229
|
-
install_client_secret(path)
|
|
245
|
+
installed = install_client_secret(path, keep_source=keep_download)
|
|
230
246
|
client = load_client_file(client_secret_path())
|
|
231
247
|
out.success(f"OAuth client installed ({client.client_id[:24]}…)")
|
|
248
|
+
_report_install(out, installed, client_id=client.client_id)
|
|
232
249
|
|
|
233
250
|
_finish_with_login(app_ctx, no_browser=no_browser)
|
|
234
251
|
|
|
@@ -237,6 +254,18 @@ def register(auth_app: typer.Typer) -> None:
|
|
|
237
254
|
auth_app.registered_commands.insert(0, auth_app.registered_commands.pop())
|
|
238
255
|
|
|
239
256
|
|
|
257
|
+
def _report_install(out: Renderer, installed, client_id: str | None = None) -> None:
|
|
258
|
+
"""Say what became of the file the client came from.
|
|
259
|
+
|
|
260
|
+
The user handed a path to a file they can see; if gmcli deleted it, that
|
|
261
|
+
has to be stated rather than discovered.
|
|
262
|
+
"""
|
|
263
|
+
if client_id is None:
|
|
264
|
+
client_id = load_client_file(client_secret_path()).client_id
|
|
265
|
+
for level, message in install_notes(installed, client_id):
|
|
266
|
+
(out.warn if level == "warn" else out.info)(message)
|
|
267
|
+
|
|
268
|
+
|
|
240
269
|
def _intro(out: Renderer) -> None:
|
|
241
270
|
out.info(
|
|
242
271
|
"\n[bold]Setting up Google sign-in for gmcli[/bold]\n\n"
|
|
@@ -30,6 +30,31 @@ SECRET_MODE = 0o600
|
|
|
30
30
|
SECRET_DIR_MODE = 0o700
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# Where a browser lands a file. These are transit, not storage: nothing in
|
|
34
|
+
# them is where the user meant to keep it, and a directory every download in
|
|
35
|
+
# the world passes through is the wrong home for an OAuth client. Anything
|
|
36
|
+
# gmcli installs from here it takes *out* of here — see
|
|
37
|
+
# ``auth/flow.py:install_client_secret``. The current directory is
|
|
38
|
+
# deliberately absent: setup looks there for a downloaded client, but a file
|
|
39
|
+
# someone chose to run the command next to is not in transit.
|
|
40
|
+
DOWNLOAD_DIRS = ("~/Downloads", "~/Desktop")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_in_download_dir(path: Path) -> bool:
|
|
44
|
+
"""Whether ``path`` sits directly in a browser's download directory."""
|
|
45
|
+
try:
|
|
46
|
+
parent = path.expanduser().resolve().parent
|
|
47
|
+
except OSError: # pragma: no cover - unresolvable path
|
|
48
|
+
return False
|
|
49
|
+
for name in DOWNLOAD_DIRS:
|
|
50
|
+
try:
|
|
51
|
+
if parent == Path(name).expanduser().resolve():
|
|
52
|
+
return True
|
|
53
|
+
except OSError: # pragma: no cover - unresolvable path
|
|
54
|
+
continue
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
33
58
|
def config_dir() -> Path:
|
|
34
59
|
return Path(platformdirs.user_config_dir(APP_NAME))
|
|
35
60
|
|
|
@@ -427,8 +427,18 @@ class MailApp:
|
|
|
427
427
|
if outcome == "cancel":
|
|
428
428
|
self.state.note("Cancelled")
|
|
429
429
|
return
|
|
430
|
-
if handler is
|
|
430
|
+
if handler is None:
|
|
431
|
+
return
|
|
432
|
+
try:
|
|
431
433
|
handler(editor.text.strip())
|
|
434
|
+
except GmcliError as exc:
|
|
435
|
+
# Same contract as ``busy()``: a prompt handler runs on the key
|
|
436
|
+
# that submitted it, so anything escaping here would take the
|
|
437
|
+
# session down over one mistyped line.
|
|
438
|
+
detail = f"{exc.message} {exc.hint or ''}".strip()
|
|
439
|
+
self.state.note(detail, render.THEME["error"])
|
|
440
|
+
except Exception as exc: # noqa: BLE001 — a UI must not die on one bad line
|
|
441
|
+
self.state.note(f"{type(exc).__name__}: {exc}", render.THEME["error"])
|
|
432
442
|
|
|
433
443
|
def confirm(self, question: str, action: Callable[[], None]) -> None:
|
|
434
444
|
"""Ask for a y/n on the status line before doing something."""
|
|
@@ -1011,6 +1021,57 @@ class MailApp:
|
|
|
1011
1021
|
self.state.note(exc.message, render.THEME["warn"])
|
|
1012
1022
|
return body
|
|
1013
1023
|
|
|
1024
|
+
def _ask_recipients(
|
|
1025
|
+
self,
|
|
1026
|
+
label: str,
|
|
1027
|
+
then: Callable[[list[str]], None],
|
|
1028
|
+
*,
|
|
1029
|
+
initial: str = "",
|
|
1030
|
+
empty_note: str,
|
|
1031
|
+
retry: bool = False,
|
|
1032
|
+
) -> None:
|
|
1033
|
+
"""Prompt for addresses, and ask again if what came back is not one.
|
|
1034
|
+
|
|
1035
|
+
A typo in a footer prompt is the most ordinary thing a user can do, so
|
|
1036
|
+
it must cost them the typo and nothing else. ``validate_addresses``
|
|
1037
|
+
raises, and a prompt handler runs on the key that submitted it — so
|
|
1038
|
+
letting that escape would end the session and lose the draft. The
|
|
1039
|
+
complaint goes in the *label*: while a prompt is open it is what the
|
|
1040
|
+
footer shows, and the status line is not on screen to read.
|
|
1041
|
+
"""
|
|
1042
|
+
|
|
1043
|
+
def handler(text: str) -> None:
|
|
1044
|
+
if not text:
|
|
1045
|
+
# An empty line is how you back out, and the one case that is
|
|
1046
|
+
# not a typo to correct.
|
|
1047
|
+
self.state.note(empty_note, render.THEME["warn"])
|
|
1048
|
+
return
|
|
1049
|
+
try:
|
|
1050
|
+
recipients = compose.validate_addresses(
|
|
1051
|
+
compose.split_addresses([text]), field="to"
|
|
1052
|
+
)
|
|
1053
|
+
except GmcliError as exc:
|
|
1054
|
+
self.state.note(exc.message, render.THEME["error"])
|
|
1055
|
+
recipients = []
|
|
1056
|
+
if not recipients:
|
|
1057
|
+
# ``split_addresses`` drops some malformed input outright
|
|
1058
|
+
# rather than raising — ``bob@`` parses to nothing at all —
|
|
1059
|
+
# so an empty result off a non-empty line is a typo too.
|
|
1060
|
+
self._ask_recipients(
|
|
1061
|
+
label,
|
|
1062
|
+
then,
|
|
1063
|
+
initial=text,
|
|
1064
|
+
empty_note=empty_note,
|
|
1065
|
+
retry=True,
|
|
1066
|
+
)
|
|
1067
|
+
return
|
|
1068
|
+
then(recipients)
|
|
1069
|
+
|
|
1070
|
+
# The complaint prefixes the label rather than replacing it, and the
|
|
1071
|
+
# unprefixed label is what recurses — two typos in a row must not read
|
|
1072
|
+
# "not an address — not an address — to: ".
|
|
1073
|
+
self.ask(f"not an address — {label}" if retry else label, handler, initial)
|
|
1074
|
+
|
|
1014
1075
|
def _send(self, message, *, thread_id: str | None, verb: str) -> None:
|
|
1015
1076
|
summary = compose.describe(message)
|
|
1016
1077
|
|
|
@@ -1052,13 +1113,7 @@ class MailApp:
|
|
|
1052
1113
|
if parent is None:
|
|
1053
1114
|
return
|
|
1054
1115
|
|
|
1055
|
-
def with_recipients(
|
|
1056
|
-
recipients = compose.validate_addresses(
|
|
1057
|
-
compose.split_addresses([text]), field="to"
|
|
1058
|
-
)
|
|
1059
|
-
if not recipients:
|
|
1060
|
-
self.state.note("No recipients — nothing forwarded", render.THEME["warn"])
|
|
1061
|
-
return
|
|
1116
|
+
def with_recipients(recipients: list[str]) -> None:
|
|
1062
1117
|
body = self._edit(_header_stub(recipients, [],
|
|
1063
1118
|
compose.forward_subject(parent.subject)))
|
|
1064
1119
|
with self.busy("Building forward"):
|
|
@@ -1067,17 +1122,14 @@ class MailApp:
|
|
|
1067
1122
|
)
|
|
1068
1123
|
self._send(message, thread_id=None, verb="Forwarded")
|
|
1069
1124
|
|
|
1070
|
-
self.
|
|
1125
|
+
self._ask_recipients(
|
|
1126
|
+
"forward to: ",
|
|
1127
|
+
with_recipients,
|
|
1128
|
+
empty_note="No recipients — nothing forwarded",
|
|
1129
|
+
)
|
|
1071
1130
|
|
|
1072
1131
|
def compose_new(self) -> None:
|
|
1073
|
-
def with_recipients(
|
|
1074
|
-
recipients = compose.validate_addresses(
|
|
1075
|
-
compose.split_addresses([text]), field="to"
|
|
1076
|
-
)
|
|
1077
|
-
if not recipients:
|
|
1078
|
-
self.state.note("No recipients — nothing sent", render.THEME["warn"])
|
|
1079
|
-
return
|
|
1080
|
-
|
|
1132
|
+
def with_recipients(recipients: list[str]) -> None:
|
|
1081
1133
|
def with_subject(subject: str) -> None:
|
|
1082
1134
|
body = self._edit(_header_stub(recipients, [], subject))
|
|
1083
1135
|
if not body:
|
|
@@ -1096,7 +1148,9 @@ class MailApp:
|
|
|
1096
1148
|
|
|
1097
1149
|
self.ask("subject: ", with_subject)
|
|
1098
1150
|
|
|
1099
|
-
self.
|
|
1151
|
+
self._ask_recipients(
|
|
1152
|
+
"to: ", with_recipients, empty_note="No recipients — nothing sent"
|
|
1153
|
+
)
|
|
1100
1154
|
|
|
1101
1155
|
|
|
1102
1156
|
def _select_attachments(
|
|
@@ -203,4 +203,9 @@ def isolated_dirs(tmp_path, monkeypatch):
|
|
|
203
203
|
monkeypatch.setenv(var, str(target))
|
|
204
204
|
# Never let a test reach the developer's real keyring.
|
|
205
205
|
monkeypatch.setenv("GMCLI_TOKEN_STORE", "file")
|
|
206
|
+
# …nor their real home. `~/Downloads` is a path gmcli both reads and
|
|
207
|
+
# *deletes from* (see `auth/flow.py:install_client_secret`), and it does
|
|
208
|
+
# not come from XDG, so redirecting HOME is the only thing between the
|
|
209
|
+
# suite and a developer's actual download folder.
|
|
210
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
206
211
|
return tmp_path
|
|
@@ -8,7 +8,7 @@ import time
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
10
|
from gmcli.auth import client_config as cc
|
|
11
|
-
from gmcli.config import Config, client_secret_path
|
|
11
|
+
from gmcli.config import Config, client_secret_path, is_in_download_dir
|
|
12
12
|
from gmcli.errors import AuthError
|
|
13
13
|
|
|
14
14
|
|
|
@@ -233,3 +233,126 @@ def test_unrelated_files_are_not_picked_up(tmp_path, monkeypatch):
|
|
|
233
233
|
|
|
234
234
|
monkeypatch.setattr(setup, "DOWNLOAD_DIRS", (str(downloads),))
|
|
235
235
|
assert setup.find_downloaded_client() == []
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# -- a downloaded client is moved, not copied --------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def downloaded(tmp_path, payload: dict | None = None, name: str = "client_secret_1.json"):
|
|
242
|
+
"""A client JSON sitting in ``~/Downloads``, as a browser would leave it."""
|
|
243
|
+
downloads = tmp_path / "Downloads"
|
|
244
|
+
downloads.mkdir(exist_ok=True)
|
|
245
|
+
path = downloads / name
|
|
246
|
+
path.write_text(json.dumps(payload or desktop_payload()), encoding="utf-8")
|
|
247
|
+
return path
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_a_client_in_downloads_is_moved_out_of_it(isolated_dirs):
|
|
251
|
+
from gmcli.auth import flow
|
|
252
|
+
|
|
253
|
+
source = downloaded(isolated_dirs)
|
|
254
|
+
result = flow.install_client_secret(source)
|
|
255
|
+
|
|
256
|
+
assert result.moved is True
|
|
257
|
+
assert not source.exists() # the browser's copy is gone
|
|
258
|
+
assert client_secret_path().exists()
|
|
259
|
+
assert cc.load_client_file(client_secret_path()).client_secret == "shh"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_the_installed_copy_is_private(isolated_dirs):
|
|
263
|
+
from gmcli.auth import flow
|
|
264
|
+
|
|
265
|
+
flow.install_client_secret(downloaded(isolated_dirs))
|
|
266
|
+
assert client_secret_path().stat().st_mode & 0o777 == 0o600
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_a_client_kept_elsewhere_is_only_copied(isolated_dirs, tmp_path):
|
|
270
|
+
"""A path someone chose is a path they meant; deleting it is a surprise."""
|
|
271
|
+
from gmcli.auth import flow
|
|
272
|
+
|
|
273
|
+
kept = tmp_path / "dotfiles" / "client.json"
|
|
274
|
+
kept.parent.mkdir()
|
|
275
|
+
kept.write_text(json.dumps(desktop_payload()), encoding="utf-8")
|
|
276
|
+
|
|
277
|
+
result = flow.install_client_secret(kept)
|
|
278
|
+
assert result.moved is False
|
|
279
|
+
assert kept.exists()
|
|
280
|
+
assert client_secret_path().exists()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_keep_source_leaves_the_download_alone(isolated_dirs):
|
|
284
|
+
from gmcli.auth import flow
|
|
285
|
+
|
|
286
|
+
source = downloaded(isolated_dirs)
|
|
287
|
+
result = flow.install_client_secret(source, keep_source=True)
|
|
288
|
+
assert result.moved is False
|
|
289
|
+
assert source.exists()
|
|
290
|
+
assert client_secret_path().exists()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_an_invalid_client_is_rejected_before_anything_is_deleted(isolated_dirs):
|
|
294
|
+
"""Nothing is removed on a path that never installed anything."""
|
|
295
|
+
from gmcli.auth import flow
|
|
296
|
+
|
|
297
|
+
source = downloaded(isolated_dirs, {"web": {"client_id": "x"}})
|
|
298
|
+
with pytest.raises(AuthError):
|
|
299
|
+
flow.install_client_secret(source)
|
|
300
|
+
assert source.exists()
|
|
301
|
+
assert not client_secret_path().exists()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_an_unremovable_download_is_reported_not_swallowed(isolated_dirs, monkeypatch):
|
|
305
|
+
"""The client still installs; the point is that the user is told."""
|
|
306
|
+
from gmcli.auth import flow
|
|
307
|
+
|
|
308
|
+
source = downloaded(isolated_dirs)
|
|
309
|
+
|
|
310
|
+
def refuse(self):
|
|
311
|
+
raise OSError("Permission denied")
|
|
312
|
+
|
|
313
|
+
monkeypatch.setattr("pathlib.Path.unlink", refuse)
|
|
314
|
+
result = flow.install_client_secret(source)
|
|
315
|
+
|
|
316
|
+
assert result.moved is False
|
|
317
|
+
assert result.left_behind == "Permission denied"
|
|
318
|
+
assert client_secret_path().exists()
|
|
319
|
+
levels = [level for level, _ in flow.install_notes(result, "111-abc")]
|
|
320
|
+
assert "warn" in levels
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_a_second_copy_of_the_same_client_is_reported(isolated_dirs):
|
|
324
|
+
"""Browsers name a re-download `… (1).json`, and one left behind is the
|
|
325
|
+
same live credential the move was meant to get rid of."""
|
|
326
|
+
from gmcli.auth import flow
|
|
327
|
+
|
|
328
|
+
twin = downloaded(isolated_dirs, name="client_secret_1 (1).json")
|
|
329
|
+
source = downloaded(isolated_dirs)
|
|
330
|
+
|
|
331
|
+
result = flow.install_client_secret(source)
|
|
332
|
+
notes = flow.install_notes(result, "111-abc.apps.googleusercontent.com")
|
|
333
|
+
|
|
334
|
+
assert twin.exists() # reported, never deleted
|
|
335
|
+
assert any(level == "warn" and str(twin) in text for level, text in notes)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_a_different_client_in_downloads_is_not_mentioned(isolated_dirs):
|
|
339
|
+
from gmcli.auth import flow
|
|
340
|
+
|
|
341
|
+
other = downloaded(
|
|
342
|
+
isolated_dirs,
|
|
343
|
+
desktop_payload("999-zzz.apps.googleusercontent.com"),
|
|
344
|
+
name="client_secret_other.json",
|
|
345
|
+
)
|
|
346
|
+
result = flow.install_client_secret(downloaded(isolated_dirs))
|
|
347
|
+
notes = flow.install_notes(result, "111-abc.apps.googleusercontent.com")
|
|
348
|
+
|
|
349
|
+
assert other.exists()
|
|
350
|
+
assert not any(str(other) in text for _, text in notes)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_the_current_directory_is_not_a_download_folder(isolated_dirs, tmp_path):
|
|
354
|
+
"""`.` is searched for a client but never emptied of one."""
|
|
355
|
+
from gmcli.commands import setup
|
|
356
|
+
|
|
357
|
+
assert "." in setup.DOWNLOAD_DIRS
|
|
358
|
+
assert not is_in_download_dir(tmp_path / "here" / "client_secret.json")
|
|
@@ -566,6 +566,35 @@ def test_version():
|
|
|
566
566
|
assert "gmcli" in result.output
|
|
567
567
|
|
|
568
568
|
|
|
569
|
+
def test_global_options_with_no_command_show_the_help(env):
|
|
570
|
+
"""A bare "Missing command." names the problem and nothing else."""
|
|
571
|
+
result = invoke("--quiet")
|
|
572
|
+
assert result.exit_code == 2
|
|
573
|
+
assert "Usage:" in result.output
|
|
574
|
+
assert "login" in result.output
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def test_no_command_under_json_keeps_stdout_empty(env):
|
|
578
|
+
"""The help would land on the pipe, so the error goes to stderr instead."""
|
|
579
|
+
result = invoke("--json")
|
|
580
|
+
assert result.exit_code == 2
|
|
581
|
+
assert result.stdout == ""
|
|
582
|
+
assert "No command given" in result.output
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def test_a_mistyped_flag_is_not_taken_as_an_account(env):
|
|
586
|
+
"""`--account -A` used to make "-A" the mailbox and fail much later."""
|
|
587
|
+
result = invoke("--account", "-A", "ls")
|
|
588
|
+
assert result.exit_code == 2
|
|
589
|
+
assert "email address" in result.output
|
|
590
|
+
assert not env.calls
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def test_account_still_accepts_an_address(env):
|
|
594
|
+
seed_threads(env)
|
|
595
|
+
assert invoke("--account", ACCOUNT, "ls").exit_code == 0
|
|
596
|
+
|
|
597
|
+
|
|
569
598
|
def test_cache_clear(env):
|
|
570
599
|
seed_threads(env)
|
|
571
600
|
invoke("ls")
|
|
@@ -617,6 +646,86 @@ def fake_login(monkeypatch):
|
|
|
617
646
|
return calls
|
|
618
647
|
|
|
619
648
|
|
|
649
|
+
@pytest.fixture
|
|
650
|
+
def fake_auth_login(monkeypatch):
|
|
651
|
+
"""Stub the consent flow for `auth login`, as ``fake_login`` does for setup."""
|
|
652
|
+
from gmcli.auth.client_config import ClientConfig
|
|
653
|
+
from gmcli.auth.store import FileStore
|
|
654
|
+
from gmcli.commands import auth as auth_mod
|
|
655
|
+
|
|
656
|
+
calls = []
|
|
657
|
+
|
|
658
|
+
def _login(**kwargs):
|
|
659
|
+
calls.append(kwargs)
|
|
660
|
+
store = FileStore()
|
|
661
|
+
store.save(ACCOUNT, {"token": "t", "refresh_token": "r"})
|
|
662
|
+
return ACCOUNT, store, ClientConfig(
|
|
663
|
+
"999-xyz.apps.googleusercontent.com", "shh", "installed client"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
monkeypatch.setattr(auth_mod, "login", _login)
|
|
667
|
+
return calls
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def test_login_moves_a_downloaded_client_out_of_downloads(
|
|
671
|
+
isolated_dirs, tmp_path, fake_auth_login
|
|
672
|
+
):
|
|
673
|
+
from gmcli.config import client_secret_path
|
|
674
|
+
|
|
675
|
+
downloads = tmp_path / "Downloads"
|
|
676
|
+
downloads.mkdir()
|
|
677
|
+
path = desktop_client_file(downloads)
|
|
678
|
+
|
|
679
|
+
result = invoke("auth", "login", "--credentials", str(path))
|
|
680
|
+
|
|
681
|
+
assert result.exit_code == 0, result.output
|
|
682
|
+
assert not path.exists()
|
|
683
|
+
assert client_secret_path().exists()
|
|
684
|
+
assert "Moved" in result.output
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def test_login_can_be_told_to_leave_the_download(
|
|
688
|
+
isolated_dirs, tmp_path, fake_auth_login
|
|
689
|
+
):
|
|
690
|
+
downloads = tmp_path / "Downloads"
|
|
691
|
+
downloads.mkdir()
|
|
692
|
+
path = desktop_client_file(downloads)
|
|
693
|
+
|
|
694
|
+
invoke("auth", "login", "--credentials", str(path), "--keep-download")
|
|
695
|
+
assert path.exists()
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def test_logout_is_reachable_without_the_auth_group(env):
|
|
699
|
+
from gmcli.auth.store import list_accounts
|
|
700
|
+
|
|
701
|
+
result = invoke("logout")
|
|
702
|
+
assert result.exit_code == 0, result.output
|
|
703
|
+
assert list_accounts() == []
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def test_login_is_reachable_without_the_auth_group(
|
|
707
|
+
isolated_dirs, tmp_path, fake_auth_login
|
|
708
|
+
):
|
|
709
|
+
path = desktop_client_file(tmp_path)
|
|
710
|
+
result = invoke("login", "--credentials", str(path))
|
|
711
|
+
|
|
712
|
+
assert result.exit_code == 0, result.output
|
|
713
|
+
assert fake_auth_login, "the consent flow was never reached"
|
|
714
|
+
assert ACCOUNT in result.output
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def test_the_two_spellings_of_login_and_logout_cannot_drift():
|
|
718
|
+
"""`gmail login` is the same command object as `gmail auth login`."""
|
|
719
|
+
import typer.main
|
|
720
|
+
|
|
721
|
+
root = typer.main.get_command(app)
|
|
722
|
+
auth_group = root.get_command(None, "auth")
|
|
723
|
+
for name in ("login", "logout"):
|
|
724
|
+
top = root.get_command(None, name)
|
|
725
|
+
grouped = auth_group.get_command(None, name)
|
|
726
|
+
assert [p.opts for p in top.params] == [p.opts for p in grouped.params]
|
|
727
|
+
|
|
728
|
+
|
|
620
729
|
def test_setup_is_registered(env):
|
|
621
730
|
result = invoke("auth", "setup", "--help")
|
|
622
731
|
assert result.exit_code == 0
|
|
@@ -647,6 +756,41 @@ def test_setup_installs_the_client_for_later_logins(
|
|
|
647
756
|
assert resolve_client().client_id == "999-xyz.apps.googleusercontent.com"
|
|
648
757
|
|
|
649
758
|
|
|
759
|
+
def test_setup_moves_a_downloaded_client_out_of_downloads(
|
|
760
|
+
isolated_dirs, tmp_path, fake_login
|
|
761
|
+
):
|
|
762
|
+
"""The whole point: after setup, no OAuth client is left in ~/Downloads."""
|
|
763
|
+
from gmcli.config import client_secret_path
|
|
764
|
+
|
|
765
|
+
downloads = tmp_path / "Downloads"
|
|
766
|
+
downloads.mkdir()
|
|
767
|
+
path = desktop_client_file(downloads)
|
|
768
|
+
|
|
769
|
+
result = invoke("auth", "setup", "--credentials", str(path))
|
|
770
|
+
|
|
771
|
+
assert result.exit_code == 0, result.output
|
|
772
|
+
assert not path.exists()
|
|
773
|
+
assert client_secret_path().exists()
|
|
774
|
+
assert "Moved" in result.output
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def test_setup_can_be_told_to_leave_the_download(isolated_dirs, tmp_path, fake_login):
|
|
778
|
+
downloads = tmp_path / "Downloads"
|
|
779
|
+
downloads.mkdir()
|
|
780
|
+
path = desktop_client_file(downloads)
|
|
781
|
+
|
|
782
|
+
invoke("auth", "setup", "--credentials", str(path), "--keep-download")
|
|
783
|
+
assert path.exists()
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def test_setup_does_not_delete_a_client_kept_elsewhere(
|
|
787
|
+
isolated_dirs, tmp_path, fake_login
|
|
788
|
+
):
|
|
789
|
+
path = desktop_client_file(tmp_path)
|
|
790
|
+
invoke("auth", "setup", "--credentials", str(path))
|
|
791
|
+
assert path.exists()
|
|
792
|
+
|
|
793
|
+
|
|
650
794
|
def test_setup_sets_the_default_account(isolated_dirs, tmp_path, fake_login):
|
|
651
795
|
from gmcli.config import Config
|
|
652
796
|
|
|
@@ -1526,3 +1526,77 @@ def test_the_reader_surface_reaches_the_right_hand_edge(ctx):
|
|
|
1526
1526
|
press(ui, "enter")
|
|
1527
1527
|
for line in render.reader(ui.state, 100, 25):
|
|
1528
1528
|
assert str(render.exact(line, 100).style) == render.THEME["page"]
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
# -- composing ----------------------------------------------------------------
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def test_a_mistyped_address_asks_again_instead_of_ending_the_session(ctx):
|
|
1535
|
+
"""The most ordinary mistake there is must cost the typo and nothing else."""
|
|
1536
|
+
ui = build(ctx, [])
|
|
1537
|
+
press(ui, "c")
|
|
1538
|
+
type_in(ui, "snehangshubhuin2018")
|
|
1539
|
+
assert ui.state.quit is False
|
|
1540
|
+
assert ui.state.prompt is not None # still asking, not gone
|
|
1541
|
+
assert "not an address" in ui.state.prompt.label
|
|
1542
|
+
assert "to: " in ui.state.prompt.label
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def test_two_typos_in_a_row_do_not_stack_the_complaint(ctx):
|
|
1546
|
+
ui = build(ctx, [])
|
|
1547
|
+
press(ui, "c")
|
|
1548
|
+
type_in(ui, "nonsense")
|
|
1549
|
+
type_in(ui, "still nonsense")
|
|
1550
|
+
assert ui.state.prompt.label == "not an address — to: "
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def test_the_rejected_text_comes_back_to_be_edited(ctx):
|
|
1554
|
+
"""Re-typing a whole address to fix one missing character is a punishment."""
|
|
1555
|
+
ui = build(ctx, [])
|
|
1556
|
+
press(ui, "c")
|
|
1557
|
+
type_in(ui, "bob@")
|
|
1558
|
+
assert ui.state.prompt.text == "bob@"
|
|
1559
|
+
assert ui.state.prompt.cursor == len("bob@")
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def test_a_corrected_address_carries_on_to_the_subject(ctx):
|
|
1563
|
+
ui = build(ctx, [])
|
|
1564
|
+
press(ui, "c")
|
|
1565
|
+
type_in(ui, "nonsense")
|
|
1566
|
+
type_in(ui, "bob@example.com")
|
|
1567
|
+
assert ui.state.prompt is not None
|
|
1568
|
+
assert ui.state.prompt.label == "subject: "
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def test_an_empty_recipient_list_abandons_rather_than_re_asking(ctx):
|
|
1572
|
+
"""Submitting nothing is how you back out; it is not a typo to correct."""
|
|
1573
|
+
ui = build(ctx, [])
|
|
1574
|
+
press(ui, "c")
|
|
1575
|
+
type_in(ui, "")
|
|
1576
|
+
assert ui.state.prompt is None
|
|
1577
|
+
assert ui.state.status_style == render.THEME["warn"]
|
|
1578
|
+
assert ui.state.quit is False
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def test_forwarding_to_a_bad_address_also_asks_again(ctx):
|
|
1582
|
+
ui = build(ctx, [])
|
|
1583
|
+
press(ui, "enter", "f")
|
|
1584
|
+
type_in(ui, "not-an-address")
|
|
1585
|
+
assert ui.state.quit is False
|
|
1586
|
+
assert "forward to: " in ui.state.prompt.label
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def test_a_prompt_handler_that_raises_lands_on_the_status_line(ctx):
|
|
1590
|
+
"""The same contract as ``busy()``, for the keys that submit a prompt."""
|
|
1591
|
+
from gmcli.errors import UsageError
|
|
1592
|
+
|
|
1593
|
+
ui = build(ctx, [])
|
|
1594
|
+
|
|
1595
|
+
def explode(_text: str) -> None:
|
|
1596
|
+
raise UsageError("nope", hint="try that again")
|
|
1597
|
+
|
|
1598
|
+
ui.ask("thing: ", explode)
|
|
1599
|
+
press(ui, "x", "enter")
|
|
1600
|
+
assert ui.state.quit is False
|
|
1601
|
+
assert ui.state.status_style == render.THEME["error"]
|
|
1602
|
+
assert "nope" in ui.state.status
|
|
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
|
|
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
|