nextcloud-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,266 @@
1
+ """Contacts (CardDAV) operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from xml.etree import ElementTree as ET
7
+ from xml.sax.saxutils import escape as xml_escape
8
+
9
+ import click
10
+ import vobject
11
+
12
+ from nextcloud_cli.client import http_client
13
+ from nextcloud_cli.config import Config, load
14
+ from nextcloud_cli.rendering import (
15
+ render_addressbooks,
16
+ render_contact,
17
+ render_contacts,
18
+ render_status,
19
+ )
20
+ from nextcloud_cli.utils import CONTEXT_SETTINGS, fail, json_option, spinner, verbose_option
21
+
22
+ CARDDAV_NS = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
23
+
24
+ PROPFIND_ADDRESSBOOKS = """<?xml version="1.0" encoding="utf-8"?>
25
+ <d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
26
+ <d:prop>
27
+ <d:displayname/>
28
+ <d:resourcetype/>
29
+ </d:prop>
30
+ </d:propfind>"""
31
+
32
+ REPORT_ADDRESSBOOK_QUERY = """<?xml version="1.0" encoding="utf-8"?>
33
+ <card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
34
+ <d:prop>
35
+ <d:getetag/>
36
+ <card:address-data/>
37
+ </d:prop>
38
+ </card:addressbook-query>"""
39
+
40
+
41
+ @click.group(context_settings=CONTEXT_SETTINGS)
42
+ def contacts() -> None:
43
+ """Manage contacts via CardDAV."""
44
+
45
+
46
+ def _abook_url(cfg: Config, name: str) -> str:
47
+ return f"{cfg.carddav_principal.rstrip('/')}/{name}/"
48
+
49
+
50
+ def _vcard_to_dict(card: vobject.base.Component) -> dict:
51
+ fn = getattr(card, "fn", None)
52
+ emails = [e.value for e in card.contents.get("email", [])]
53
+ tels = [t.value for t in card.contents.get("tel", [])]
54
+ return {
55
+ "uid": getattr(card, "uid", None).value if hasattr(card, "uid") else None,
56
+ "fn": fn.value if fn else None,
57
+ "emails": emails,
58
+ "phones": tels,
59
+ }
60
+
61
+
62
+ @verbose_option
63
+ @json_option
64
+ @contacts.command("list")
65
+ def list_addressbooks(json_output: bool) -> None:
66
+ """List address books."""
67
+ cfg = load()
68
+ with spinner("Fetching address books", json_output):
69
+ with http_client(cfg, accept="application/xml") as http:
70
+ response = http.request(
71
+ "PROPFIND",
72
+ cfg.carddav_principal,
73
+ content=PROPFIND_ADDRESSBOOKS,
74
+ headers={"Depth": "1", "Content-Type": "application/xml"},
75
+ )
76
+ if response.status_code >= 400:
77
+ fail(f"PROPFIND failed: {response.status_code}")
78
+
79
+ root = ET.fromstring(response.text)
80
+ books = []
81
+ for resp in root.findall("d:response", CARDDAV_NS):
82
+ href = resp.findtext("d:href", "", CARDDAV_NS)
83
+ if href.rstrip("/") == cfg.carddav_principal.rstrip("/").split(cfg.url, 1)[-1].rstrip("/"):
84
+ continue
85
+ display = resp.findtext(".//d:displayname", "", CARDDAV_NS)
86
+ if display:
87
+ books.append({"name": Path(href.rstrip("/")).name, "displayname": display, "href": href})
88
+ render_addressbooks(books, json_output)
89
+
90
+
91
+ _PROP_FIELDS = {
92
+ "name": ("FN",),
93
+ "email": ("EMAIL",),
94
+ "phone": ("TEL",),
95
+ "all": ("FN", "EMAIL", "TEL"),
96
+ }
97
+
98
+
99
+ def _build_search_report(query: str, fields: tuple[str, ...]) -> str:
100
+ """Build a CardDAV addressbook-query REPORT body with text-match filters."""
101
+ safe = xml_escape(query)
102
+ filters = "\n".join(
103
+ f' <card:prop-filter name="{f}">'
104
+ f' <card:text-match collation="i;unicode-casemap" match-type="contains">{safe}</card:text-match>'
105
+ f' </card:prop-filter>'
106
+ for f in fields
107
+ )
108
+ return f"""<?xml version="1.0" encoding="utf-8"?>
109
+ <card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
110
+ <d:prop>
111
+ <d:getetag/>
112
+ <card:address-data/>
113
+ </d:prop>
114
+ <card:filter test="anyof">
115
+ {filters}
116
+ </card:filter>
117
+ </card:addressbook-query>"""
118
+
119
+
120
+ @verbose_option
121
+ @json_option
122
+ @contacts.command()
123
+ @click.option("--addressbook", required=True)
124
+ @click.option("--query", required=True, help="Substring to match (case-insensitive).")
125
+ @click.option(
126
+ "--in",
127
+ "field",
128
+ type=click.Choice(list(_PROP_FIELDS.keys())),
129
+ default="all",
130
+ help="Which vCard property to search (default: all).",
131
+ )
132
+ def search(addressbook: str, query: str, field: str, json_output: bool) -> None:
133
+ """Server-side search across contacts in an address book (CardDAV text-match)."""
134
+ cfg = load()
135
+ body = _build_search_report(query, _PROP_FIELDS[field])
136
+ with spinner(f"Searching '{query}' in {addressbook}", json_output):
137
+ with http_client(cfg, accept="application/xml") as http:
138
+ response = http.request(
139
+ "REPORT",
140
+ _abook_url(cfg, addressbook),
141
+ content=body,
142
+ headers={"Depth": "1", "Content-Type": "application/xml"},
143
+ )
144
+ if response.status_code >= 400:
145
+ fail(f"REPORT failed: {response.status_code}: {response.text}")
146
+
147
+ root = ET.fromstring(response.text)
148
+ out = []
149
+ for resp in root.findall("d:response", CARDDAV_NS):
150
+ href = resp.findtext("d:href", "", CARDDAV_NS)
151
+ data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
152
+ if not data:
153
+ continue
154
+ try:
155
+ card = vobject.readOne(data)
156
+ except Exception:
157
+ continue
158
+ item = _vcard_to_dict(card)
159
+ item["href"] = href
160
+ out.append(item)
161
+ render_contacts(out, json_output)
162
+
163
+
164
+ @verbose_option
165
+ @json_option
166
+ @contacts.command()
167
+ @click.option("--addressbook", required=True)
168
+ def cards(addressbook: str, json_output: bool) -> None:
169
+ """List contacts in an address book."""
170
+ cfg = load()
171
+ with spinner(f"Fetching contacts from {addressbook}", json_output):
172
+ with http_client(cfg, accept="application/xml") as http:
173
+ response = http.request(
174
+ "REPORT",
175
+ _abook_url(cfg, addressbook),
176
+ content=REPORT_ADDRESSBOOK_QUERY,
177
+ headers={"Depth": "1", "Content-Type": "application/xml"},
178
+ )
179
+ if response.status_code >= 400:
180
+ fail(f"REPORT failed: {response.status_code}")
181
+
182
+ root = ET.fromstring(response.text)
183
+ out = []
184
+ for resp in root.findall("d:response", CARDDAV_NS):
185
+ href = resp.findtext("d:href", "", CARDDAV_NS)
186
+ data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
187
+ if not data:
188
+ continue
189
+ try:
190
+ card = vobject.readOne(data)
191
+ except Exception:
192
+ continue
193
+ item = _vcard_to_dict(card)
194
+ item["href"] = href
195
+ out.append(item)
196
+ render_contacts(out, json_output)
197
+
198
+
199
+ @verbose_option
200
+ @json_option
201
+ @contacts.command()
202
+ @click.option("--addressbook", required=True)
203
+ @click.option("--uid", required=True)
204
+ def get(addressbook: str, uid: str, json_output: bool) -> None:
205
+ """Fetch a single contact by UID."""
206
+ cfg = load()
207
+ with spinner(f"Fetching contact {uid}", json_output):
208
+ with http_client(cfg, accept="application/xml") as http:
209
+ response = http.request(
210
+ "REPORT",
211
+ _abook_url(cfg, addressbook),
212
+ content=REPORT_ADDRESSBOOK_QUERY,
213
+ headers={"Depth": "1", "Content-Type": "application/xml"},
214
+ )
215
+ if response.status_code >= 400:
216
+ fail(f"REPORT failed: {response.status_code}")
217
+
218
+ root = ET.fromstring(response.text)
219
+ for resp in root.findall("d:response", CARDDAV_NS):
220
+ data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
221
+ if not data:
222
+ continue
223
+ try:
224
+ card = vobject.readOne(data)
225
+ except Exception:
226
+ continue
227
+ if hasattr(card, "uid") and card.uid.value == uid:
228
+ render_contact(_vcard_to_dict(card), json_output)
229
+ return
230
+ fail(f"contact not found: {uid}")
231
+
232
+
233
+ @verbose_option
234
+ @json_option
235
+ @contacts.command()
236
+ @click.option("--addressbook", required=True)
237
+ @click.option("--uid", required=True)
238
+ @click.option("--local", "local", required=True, type=click.Path(dir_okay=False))
239
+ def export(addressbook: str, uid: str, local: str, json_output: bool) -> None:
240
+ """Export a contact as a vCard file."""
241
+ cfg = load()
242
+ with spinner(f"Exporting contact {uid}", json_output):
243
+ with http_client(cfg, accept="application/xml") as http:
244
+ response = http.request(
245
+ "REPORT",
246
+ _abook_url(cfg, addressbook),
247
+ content=REPORT_ADDRESSBOOK_QUERY,
248
+ headers={"Depth": "1", "Content-Type": "application/xml"},
249
+ )
250
+ if response.status_code >= 400:
251
+ fail(f"REPORT failed: {response.status_code}")
252
+
253
+ root = ET.fromstring(response.text)
254
+ for resp in root.findall("d:response", CARDDAV_NS):
255
+ data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
256
+ if not data:
257
+ continue
258
+ try:
259
+ card = vobject.readOne(data)
260
+ except Exception:
261
+ continue
262
+ if hasattr(card, "uid") and card.uid.value == uid:
263
+ Path(local).write_text(card.serialize())
264
+ render_status("exported", json_output, uid=uid, path=local)
265
+ return
266
+ fail(f"contact not found: {uid}")
@@ -0,0 +1,166 @@
1
+ """File operations against the user's Nextcloud WebDAV root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from webdav4.client import ResourceAlreadyExists, ResourceNotFound
9
+
10
+ from nextcloud_cli.client import http_client, webdav_client
11
+ from nextcloud_cli.config import load
12
+ from nextcloud_cli.rendering import render_files_list, render_status
13
+ from nextcloud_cli.utils import (
14
+ CONTEXT_SETTINGS,
15
+ fail,
16
+ format_size,
17
+ json_option,
18
+ spinner,
19
+ verbose_option,
20
+ )
21
+
22
+
23
+ @click.group(context_settings=CONTEXT_SETTINGS)
24
+ def files() -> None:
25
+ """Manage files via WebDAV."""
26
+
27
+
28
+ @verbose_option
29
+ @json_option
30
+ @files.command("list")
31
+ @click.option("--path", default="/", help="Remote directory path.")
32
+ def list_(path: str, json_output: bool) -> None:
33
+ """List files in a remote directory."""
34
+ cfg = load()
35
+ client = webdav_client(cfg)
36
+ try:
37
+ with spinner(f"Listing {path}", json_output):
38
+ entries = client.ls(path, detail=True)
39
+ except ResourceNotFound:
40
+ fail(f"path not found: {path}")
41
+ items = [
42
+ {
43
+ "name": Path(entry["name"]).name or entry["name"],
44
+ "path": entry["name"],
45
+ "type": entry["type"],
46
+ "size": entry.get("content_length") or 0,
47
+ "size_human": format_size(entry.get("content_length") or 0),
48
+ "modified": str(entry.get("modified")) if entry.get("modified") else None,
49
+ }
50
+ for entry in entries
51
+ ]
52
+ render_files_list(items, path, json_output)
53
+
54
+
55
+ @verbose_option
56
+ @json_option
57
+ @files.command()
58
+ @click.option("--local", "local", required=True, type=click.Path(exists=True, dir_okay=False))
59
+ @click.option("--remote", required=True, help="Destination path on the server.")
60
+ def upload(local: str, remote: str, json_output: bool) -> None:
61
+ """Upload a local file to the server."""
62
+ cfg = load()
63
+ client = webdav_client(cfg)
64
+ with spinner(f"Uploading {local} → {remote}", json_output):
65
+ client.upload_file(from_path=local, to_path=remote, overwrite=True)
66
+ render_status("uploaded", json_output, local=local, remote=remote)
67
+
68
+
69
+ @verbose_option
70
+ @json_option
71
+ @files.command()
72
+ @click.option("--remote", required=True, help="Source path on the server.")
73
+ @click.option("--local", "local", required=True, type=click.Path(dir_okay=False))
74
+ def download(remote: str, local: str, json_output: bool) -> None:
75
+ """Download a remote file to the local filesystem."""
76
+ cfg = load()
77
+ client = webdav_client(cfg)
78
+ try:
79
+ with spinner(f"Downloading {remote} → {local}", json_output):
80
+ client.download_file(from_path=remote, to_path=local)
81
+ except ResourceNotFound:
82
+ fail(f"remote file not found: {remote}")
83
+ render_status("downloaded", json_output, remote=remote, local=local)
84
+
85
+
86
+ @verbose_option
87
+ @json_option
88
+ @files.command()
89
+ @click.option("--path", required=True, help="Path to delete.")
90
+ def delete(path: str, json_output: bool) -> None:
91
+ """Delete a remote file or directory."""
92
+ cfg = load()
93
+ client = webdav_client(cfg)
94
+ try:
95
+ with spinner(f"Deleting {path}", json_output):
96
+ client.remove(path)
97
+ except ResourceNotFound:
98
+ fail(f"path not found: {path}")
99
+ render_status("deleted", json_output, path=path)
100
+
101
+
102
+ @verbose_option
103
+ @json_option
104
+ @files.command()
105
+ @click.option("--src", required=True, help="Current remote path.")
106
+ @click.option("--dst", required=True, help="New remote path.")
107
+ def move(src: str, dst: str, json_output: bool) -> None:
108
+ """Move or rename a file."""
109
+ cfg = load()
110
+ client = webdav_client(cfg)
111
+ try:
112
+ with spinner(f"Moving {src} → {dst}", json_output):
113
+ client.move(src, dst)
114
+ except ResourceNotFound:
115
+ fail(f"source not found: {src}")
116
+ render_status("moved", json_output, src=src, dst=dst)
117
+
118
+
119
+ @verbose_option
120
+ @json_option
121
+ @files.command()
122
+ @click.option("--path", required=True, help="Directory to create.")
123
+ def mkdir(path: str, json_output: bool) -> None:
124
+ """Create a remote directory."""
125
+ cfg = load()
126
+ client = webdav_client(cfg)
127
+ try:
128
+ with spinner(f"Creating {path}", json_output):
129
+ client.mkdir(path)
130
+ except ResourceAlreadyExists:
131
+ fail(f"already exists: {path}")
132
+ render_status("created", json_output, path=path)
133
+
134
+
135
+ @verbose_option
136
+ @json_option
137
+ @files.command()
138
+ @click.option("--query", required=True, help="Substring to match (server-side, OCS unified search).")
139
+ @click.option("--limit", default=None, type=click.IntRange(min=1), help="Max number of matches.")
140
+ def search(query: str, limit: int | None, json_output: bool) -> None:
141
+ """Server-side filename search via the OCS unified search API."""
142
+ cfg = load()
143
+ url = f"{cfg.url.rstrip('/')}/ocs/v2.php/search/providers/files/search"
144
+ params: dict[str, str | int] = {"term": query}
145
+ if limit is not None:
146
+ params["limit"] = limit
147
+ with spinner(f"Searching for '{query}'", json_output):
148
+ with http_client(cfg) as http:
149
+ response = http.get(url, params=params)
150
+ if response.status_code >= 400:
151
+ fail(f"search failed: {response.status_code}: {response.text}")
152
+ payload = response.json().get("ocs", {}).get("data", {})
153
+ entries = payload.get("entries", []) or []
154
+ matches = [
155
+ {
156
+ "name": entry.get("title"),
157
+ "path": entry.get("attributes", {}).get("path") or entry.get("subline") or "",
158
+ "resourceUrl": entry.get("resourceUrl"),
159
+ "fileId": entry.get("attributes", {}).get("fileId"),
160
+ "icon": entry.get("icon"),
161
+ }
162
+ for entry in entries
163
+ ]
164
+ if limit is not None:
165
+ matches = matches[:limit]
166
+ render_files_list(matches, f"matches for '{query}'", json_output)
@@ -0,0 +1,119 @@
1
+ """Nextcloud Notes app — REST/JSON API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from nextcloud_cli.client import http_client
9
+ from nextcloud_cli.config import load
10
+ from nextcloud_cli.rendering import render_note, render_notes_list, render_status
11
+ from nextcloud_cli.utils import CONTEXT_SETTINGS, fail, json_option, spinner, verbose_option
12
+
13
+
14
+ @click.group(context_settings=CONTEXT_SETTINGS)
15
+ def notes() -> None:
16
+ """Manage notes (requires the Nextcloud Notes app)."""
17
+
18
+
19
+ def _handle(response: httpx.Response) -> dict | list:
20
+ if response.status_code == 404:
21
+ fail("note not found")
22
+ if response.status_code == 401:
23
+ fail("unauthorized — check your app password")
24
+ if response.status_code >= 400:
25
+ fail(f"notes API error {response.status_code}: {response.text}")
26
+ return response.json() if response.content else {}
27
+
28
+
29
+ @verbose_option
30
+ @json_option
31
+ @notes.command("list")
32
+ @click.option("--category", default=None, help="Filter by category.")
33
+ @click.option("--limit", default=None, type=click.IntRange(min=1), help="Max number of notes to fetch.")
34
+ def list_(category: str | None, limit: int | None, json_output: bool) -> None:
35
+ """List all notes."""
36
+ cfg = load()
37
+ params: dict[str, str | int] = {}
38
+ if category is not None:
39
+ params["category"] = category
40
+ if limit is not None:
41
+ params["chunkSize"] = limit
42
+ with spinner("Fetching notes", json_output):
43
+ with http_client(cfg) as http:
44
+ data = _handle(http.get(cfg.notes_api_url, params=params or None))
45
+ notes_list = data if isinstance(data, list) else [data]
46
+ if limit is not None:
47
+ notes_list = notes_list[:limit]
48
+ render_notes_list(notes_list, json_output)
49
+
50
+
51
+ @verbose_option
52
+ @json_option
53
+ @notes.command()
54
+ @click.option("--id", "note_id", required=True, type=int)
55
+ def get(note_id: int, json_output: bool) -> None:
56
+ """Fetch a single note by ID."""
57
+ cfg = load()
58
+ with spinner(f"Fetching note {note_id}", json_output):
59
+ with http_client(cfg) as http:
60
+ data = _handle(http.get(f"{cfg.notes_api_url}/{note_id}"))
61
+ render_note(data if isinstance(data, dict) else {}, json_output)
62
+
63
+
64
+ @verbose_option
65
+ @json_option
66
+ @notes.command()
67
+ @click.option("--title", required=True)
68
+ @click.option("--content", default="", help="Body of the note.")
69
+ @click.option("--category", default="", help="Category folder.")
70
+ def create(title: str, content: str, category: str, json_output: bool) -> None:
71
+ """Create a new note."""
72
+ cfg = load()
73
+ payload = {"title": title, "content": content, "category": category}
74
+ with spinner(f"Creating note '{title}'", json_output):
75
+ with http_client(cfg) as http:
76
+ data = _handle(http.post(cfg.notes_api_url, json=payload))
77
+ if json_output:
78
+ from nextcloud_cli.utils import emit
79
+
80
+ emit(data)
81
+ else:
82
+ render_status("note created", json_output, id=data.get("id"), title=data.get("title"))
83
+
84
+
85
+ @verbose_option
86
+ @json_option
87
+ @notes.command()
88
+ @click.option("--id", "note_id", required=True, type=int)
89
+ @click.option("--title", default=None)
90
+ @click.option("--content", default=None)
91
+ @click.option("--category", default=None)
92
+ def edit(note_id: int, title: str | None, content: str | None, category: str | None, json_output: bool) -> None:
93
+ """Update an existing note. Only provided fields change."""
94
+ cfg = load()
95
+ payload = {k: v for k, v in {"title": title, "content": content, "category": category}.items() if v is not None}
96
+ if not payload:
97
+ fail("nothing to update — provide at least one of --title/--content/--category")
98
+ with spinner(f"Updating note {note_id}", json_output):
99
+ with http_client(cfg) as http:
100
+ data = _handle(http.put(f"{cfg.notes_api_url}/{note_id}", json=payload))
101
+ if json_output:
102
+ from nextcloud_cli.utils import emit
103
+
104
+ emit(data)
105
+ else:
106
+ render_status("note updated", json_output, id=note_id)
107
+
108
+
109
+ @verbose_option
110
+ @json_option
111
+ @notes.command()
112
+ @click.option("--id", "note_id", required=True, type=int)
113
+ def delete(note_id: int, json_output: bool) -> None:
114
+ """Delete a note."""
115
+ cfg = load()
116
+ with spinner(f"Deleting note {note_id}", json_output):
117
+ with http_client(cfg) as http:
118
+ _handle(http.delete(f"{cfg.notes_api_url}/{note_id}"))
119
+ render_status("deleted", json_output, id=note_id)
@@ -0,0 +1,66 @@
1
+ """Login / logout commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import click
8
+ import httpx
9
+
10
+ from nextcloud_cli import config as cfg_module
11
+ from nextcloud_cli.rendering import render_status
12
+ from nextcloud_cli.utils import CONTEXT_SETTINGS, json_option, spinner, verbose_option
13
+
14
+
15
+ def _resolve(flag_value: str | None, env_var: str, prompt: str, *, hidden: bool = False, default: str | None = None) -> str:
16
+ """flag value > env var > interactive prompt."""
17
+ if flag_value:
18
+ return flag_value
19
+ env_value = os.environ.get(env_var)
20
+ if env_value:
21
+ return env_value
22
+ return click.prompt(prompt, hide_input=hidden, default=default, type=str)
23
+
24
+
25
+ @click.command(context_settings=CONTEXT_SETTINGS)
26
+ @verbose_option
27
+ @json_option
28
+ @click.option("--url", default=None, help="Nextcloud server URL.")
29
+ @click.option("--username", default=None, help="Nextcloud username.")
30
+ @click.option("--password", default=None, help="App password (NOT your account password).")
31
+ @click.option("--timezone", default=None, help="IANA timezone (e.g. Europe/Paris).")
32
+ def login(url: str | None, username: str | None, password: str | None, timezone: str | None, json_output: bool) -> None:
33
+ """Log in to a Nextcloud server."""
34
+ url = _resolve(url, "NEXTCLOUD_URL", "Nextcloud URL").rstrip("/")
35
+ username = _resolve(username, "NEXTCLOUD_USER", "Username")
36
+ password = _resolve(password, "NEXTCLOUD_TOKEN", "App password", hidden=True)
37
+ timezone = _resolve(timezone, "NEXTCLOUD_TIMEZONE", "Timezone", default="UTC")
38
+
39
+ test_url = f"{url}/remote.php/dav/files/{username}/"
40
+ try:
41
+ with spinner(f"Authenticating to {url}", json_output):
42
+ response = httpx.request(
43
+ "PROPFIND",
44
+ test_url,
45
+ auth=(username, password),
46
+ headers={"Depth": "0"},
47
+ timeout=15.0,
48
+ )
49
+ except httpx.HTTPError as exc:
50
+ raise click.ClickException(f"could not reach server: {exc}") from exc
51
+ if response.status_code in (401, 403):
52
+ raise click.ClickException("authentication failed — check your username and app password")
53
+ if response.status_code >= 400:
54
+ raise click.ClickException(f"server returned HTTP {response.status_code}")
55
+
56
+ cfg_module.save(url=url, username=username, password=password, timezone=timezone)
57
+ render_status("logged-in", json_output, url=url, username=username, timezone=timezone)
58
+
59
+
60
+ @click.command(context_settings=CONTEXT_SETTINGS)
61
+ @verbose_option
62
+ @json_option
63
+ def logout(json_output: bool) -> None:
64
+ """Erase stored credentials."""
65
+ cfg_module.clear()
66
+ render_status("logged-out", json_output)