netconverter-cli 0.4.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.
- netconverter_cli/__init__.py +3 -0
- netconverter_cli/activity.py +83 -0
- netconverter_cli/api.py +257 -0
- netconverter_cli/backend.py +107 -0
- netconverter_cli/branding.py +79 -0
- netconverter_cli/catalog.py +162 -0
- netconverter_cli/clerk.py +602 -0
- netconverter_cli/cli.py +521 -0
- netconverter_cli/commands.py +63 -0
- netconverter_cli/connect.py +465 -0
- netconverter_cli/controller.py +331 -0
- netconverter_cli/credentials.py +94 -0
- netconverter_cli/guided.py +365 -0
- netconverter_cli/mcp_server.py +640 -0
- netconverter_cli/palette.py +16 -0
- netconverter_cli/presentation.py +254 -0
- netconverter_cli/privacy.py +105 -0
- netconverter_cli/providers.py +246 -0
- netconverter_cli/remote_mcp.py +175 -0
- netconverter_cli/setup.py +179 -0
- netconverter_cli/tui.py +607 -0
- netconverter_cli/update.py +344 -0
- netconverter_cli/workspace.py +148 -0
- netconverter_cli-0.4.0.dist-info/METADATA +167 -0
- netconverter_cli-0.4.0.dist-info/RECORD +28 -0
- netconverter_cli-0.4.0.dist-info/WHEEL +4 -0
- netconverter_cli-0.4.0.dist-info/entry_points.txt +2 -0
- netconverter_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""User-directed local orchestration; providers never receive this object."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from .workspace import Workspace, WorkspaceError
|
|
9
|
+
from .api import APIError, Client, ExternalClient
|
|
10
|
+
from .backend import EXTERNAL_MAX_BYTES, download_external, submit_external
|
|
11
|
+
from .catalog import Catalog
|
|
12
|
+
|
|
13
|
+
# Query commands → the NetConverter MCP tool of the same purpose (full API profile).
|
|
14
|
+
QUERY_TOOLS = {
|
|
15
|
+
"unused_objects": "get_unused_objects",
|
|
16
|
+
"routes": "get_route_summary",
|
|
17
|
+
"interfaces": "get_interface_summary",
|
|
18
|
+
"policy": "get_acl_summary",
|
|
19
|
+
"nat": "get_nat_summary",
|
|
20
|
+
"dependencies": "get_object_dependencies",
|
|
21
|
+
"flow": "simulate_flow",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Internal pipeline evidence stays on the server (upstream 0439995).
|
|
26
|
+
INTERNAL_EVIDENCE_FILES = frozenset({"evidence.json", "pipeline-evidence.json"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fetch_artifacts(client, job, output: Workspace) -> list:
|
|
30
|
+
"""Download every manifest artifact, verify size and SHA-256, publish atomically."""
|
|
31
|
+
saved = []
|
|
32
|
+
for item in client.artifacts(job)["artifacts"]:
|
|
33
|
+
if item["filename"] in INTERNAL_EVIDENCE_FILES:
|
|
34
|
+
continue
|
|
35
|
+
data = client.download(job, item["artifact_id"])
|
|
36
|
+
if any(data.get(k) != item[k] for k in ("filename", "sha256", "bytes")):
|
|
37
|
+
raise WorkspaceError("Artifact manifest changed; retry download")
|
|
38
|
+
try:
|
|
39
|
+
content = base64.b64decode(data["content_base64"], validate=True)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise WorkspaceError("Invalid artifact encoding") from None
|
|
42
|
+
if len(content) != item["bytes"]:
|
|
43
|
+
raise WorkspaceError("Artifact size mismatch")
|
|
44
|
+
saved.append(output.save_artifact(job, item["filename"], content, item["sha256"]))
|
|
45
|
+
return saved
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Controller:
|
|
49
|
+
"""``kind`` is the credential profile: ``external`` (full API, any vendor) or
|
|
50
|
+
``quick`` (0.1.x pilot key). ``remote`` returns a RemoteMCP for full-API queries."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, workspace, client, provider=None, *, kind=None, remote=None):
|
|
53
|
+
self.workspace = Workspace(workspace)
|
|
54
|
+
self.client = client
|
|
55
|
+
self.provider = provider
|
|
56
|
+
self.kind = kind or ("external" if isinstance(client, ExternalClient) else "quick")
|
|
57
|
+
self.remote_factory = remote
|
|
58
|
+
self._remote = None
|
|
59
|
+
self.state = self.workspace.load()
|
|
60
|
+
self.allowance = {}
|
|
61
|
+
self.catalog = Catalog()
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def external(self) -> bool:
|
|
65
|
+
return self.kind == "external"
|
|
66
|
+
|
|
67
|
+
def capabilities(self):
|
|
68
|
+
try:
|
|
69
|
+
self.allowance = self.client.capabilities()
|
|
70
|
+
except APIError as exc:
|
|
71
|
+
if self.external and exc.status == 404:
|
|
72
|
+
raise WorkspaceError("This NetConverter server does not publish its capabilities yet "
|
|
73
|
+
"(GET /capabilities); update the server, then retry /allowance") from None
|
|
74
|
+
raise
|
|
75
|
+
self.catalog = (Catalog.from_external if self.external else Catalog.from_quick)(self.allowance)
|
|
76
|
+
return self.allowance
|
|
77
|
+
|
|
78
|
+
def max_bytes(self) -> int:
|
|
79
|
+
if self.catalog.loaded:
|
|
80
|
+
return self.catalog.max_bytes
|
|
81
|
+
return EXTERNAL_MAX_BYTES if self.external else Catalog().max_bytes
|
|
82
|
+
|
|
83
|
+
def select(self, filename, vendor):
|
|
84
|
+
if self.external:
|
|
85
|
+
return self._select_local(filename, vendor)
|
|
86
|
+
path, content, digest = self.workspace.snapshot(filename)
|
|
87
|
+
result = self.client.upload(content, vendor)
|
|
88
|
+
if result["source_sha256"] != digest:
|
|
89
|
+
raise WorkspaceError("Server revision checksum mismatch")
|
|
90
|
+
handle = result["configuration_id"]
|
|
91
|
+
self.state["configurations"][handle] = {
|
|
92
|
+
"path": str(path.relative_to(self.workspace.root)),
|
|
93
|
+
"sha256": digest,
|
|
94
|
+
"vendor": vendor,
|
|
95
|
+
}
|
|
96
|
+
self.state["selected"] = handle
|
|
97
|
+
self.state.pop("active_job", None)
|
|
98
|
+
self.workspace.store(self.state)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def _select_local(self, filename, vendor):
|
|
102
|
+
"""Full API: nothing is uploaded until submit; the revision is pinned by SHA-256."""
|
|
103
|
+
path, _, digest = self.workspace.snapshot(filename, self.max_bytes())
|
|
104
|
+
handle = "local_" + digest[:16]
|
|
105
|
+
self.state["configurations"][handle] = {
|
|
106
|
+
"path": str(path.relative_to(self.workspace.root)),
|
|
107
|
+
"sha256": digest,
|
|
108
|
+
"vendor": vendor,
|
|
109
|
+
"local": True,
|
|
110
|
+
}
|
|
111
|
+
self.state["selected"] = handle
|
|
112
|
+
self.state.pop("active_job", None)
|
|
113
|
+
self.workspace.store(self.state)
|
|
114
|
+
return {"configuration_id": handle, "source_sha256": digest, "vendor": vendor, "local": True}
|
|
115
|
+
|
|
116
|
+
def _selected_revision(self, handle):
|
|
117
|
+
metadata = self.state["configurations"].get(handle) or {}
|
|
118
|
+
_, content, digest = self.workspace.snapshot(metadata.get("path", ""), self.max_bytes())
|
|
119
|
+
if digest != metadata.get("sha256"):
|
|
120
|
+
raise WorkspaceError("The selected file changed after you chose it; select it again")
|
|
121
|
+
return metadata, content
|
|
122
|
+
|
|
123
|
+
def submit(self, operation, options=None):
|
|
124
|
+
selected = self.state.get("selected")
|
|
125
|
+
if not selected:
|
|
126
|
+
raise WorkspaceError("Open a configuration first")
|
|
127
|
+
if self.external and operation == "analyze":
|
|
128
|
+
operation = "validate"
|
|
129
|
+
request = {
|
|
130
|
+
"configuration_id": selected,
|
|
131
|
+
"operation": operation,
|
|
132
|
+
"options": options or {},
|
|
133
|
+
}
|
|
134
|
+
pending = self.state.get("pending")
|
|
135
|
+
if pending and pending["request"] != request:
|
|
136
|
+
raise WorkspaceError(
|
|
137
|
+
"An interrupted submission is pending; use resume before submitting different work"
|
|
138
|
+
)
|
|
139
|
+
key = pending["key"] if pending else uuid.uuid4().hex
|
|
140
|
+
self.state["pending"] = {"key": key, "request": request, "started": time.time()}
|
|
141
|
+
self.workspace.store(self.state)
|
|
142
|
+
try:
|
|
143
|
+
if self.external:
|
|
144
|
+
metadata, content = self._selected_revision(selected)
|
|
145
|
+
settings = dict(options or {})
|
|
146
|
+
target = settings.pop("target_vendor", None)
|
|
147
|
+
result = submit_external(self.client, content, metadata["vendor"], target,
|
|
148
|
+
operation, settings, key)
|
|
149
|
+
else:
|
|
150
|
+
wire = "analyze" if operation == "validate" else operation # the pilot API's name
|
|
151
|
+
result = self.client.submit(selected, wire, options or {}, key)
|
|
152
|
+
except APIError as exc:
|
|
153
|
+
if exc.status in {400, 403, 404, 413, 422, 429}:
|
|
154
|
+
self.state.pop("pending", None)
|
|
155
|
+
self.workspace.store(self.state)
|
|
156
|
+
raise
|
|
157
|
+
self.state["jobs"][result["job_id"]] = {
|
|
158
|
+
"configuration_id": selected,
|
|
159
|
+
"operation": operation,
|
|
160
|
+
"options": options or {},
|
|
161
|
+
"submitted_at": self.state["pending"]["started"],
|
|
162
|
+
}
|
|
163
|
+
self.state["active_job"] = result["job_id"]
|
|
164
|
+
self.state.pop("pending", None)
|
|
165
|
+
self.workspace.store(self.state)
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def resume(self, job_id=None):
|
|
169
|
+
if self.state.get("pending"):
|
|
170
|
+
request = self.state["pending"]["request"]
|
|
171
|
+
return self.submit(request["operation"], request["options"])
|
|
172
|
+
job = job_id or self.state.get("active_job")
|
|
173
|
+
if not job:
|
|
174
|
+
return self.client.jobs()
|
|
175
|
+
result = self.client.job(job) if self.external else self.client.status(job)
|
|
176
|
+
self.state["active_job"] = job
|
|
177
|
+
configuration = result.get("configuration_id")
|
|
178
|
+
if configuration:
|
|
179
|
+
self.state["selected"] = configuration
|
|
180
|
+
self.state["jobs"].setdefault(job, {})["configuration_id"] = configuration
|
|
181
|
+
self.workspace.store(self.state)
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
def jobs(self):
|
|
185
|
+
return self.client.jobs()
|
|
186
|
+
|
|
187
|
+
def remote(self):
|
|
188
|
+
if self._remote is None:
|
|
189
|
+
self._remote = self.remote_factory() if self.remote_factory else None
|
|
190
|
+
if self._remote is None:
|
|
191
|
+
raise WorkspaceError("Questions about a job need NetConverter MCP access; sign in with netconverter login --clerk")
|
|
192
|
+
return self._remote
|
|
193
|
+
|
|
194
|
+
def _query_remote(self, job, operation, arguments):
|
|
195
|
+
from .remote_mcp import RemoteError
|
|
196
|
+
|
|
197
|
+
tool = QUERY_TOOLS.get(operation)
|
|
198
|
+
if not tool:
|
|
199
|
+
raise WorkspaceError("Unsupported question; use /help")
|
|
200
|
+
try:
|
|
201
|
+
remote = self.remote()
|
|
202
|
+
remote.call_tool("select_job", {"job_id": job})
|
|
203
|
+
reply = remote.call_tool(tool, arguments)
|
|
204
|
+
except RemoteError as exc:
|
|
205
|
+
raise WorkspaceError(f"NetConverter could not answer this question ({exc.code})") from None
|
|
206
|
+
details = reply.get("structuredContent")
|
|
207
|
+
if not isinstance(details, dict):
|
|
208
|
+
try:
|
|
209
|
+
details = json.loads(reply["content"][0]["text"])
|
|
210
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
211
|
+
details = {"result": "No structured result"}
|
|
212
|
+
if reply.get("isError"):
|
|
213
|
+
raise WorkspaceError(str(details.get("message") or details.get("error") or "The question failed"))
|
|
214
|
+
return {"job_id": job, "operation": operation, "details": details,
|
|
215
|
+
"query_id": operation + "_" + uuid.uuid4().hex[:12]}
|
|
216
|
+
|
|
217
|
+
def query(self, operation, arguments=None):
|
|
218
|
+
job = self.state.get("active_job")
|
|
219
|
+
if not job:
|
|
220
|
+
raise WorkspaceError("Select a completed job with resume first")
|
|
221
|
+
if self.external:
|
|
222
|
+
result = self._query_remote(job, operation, arguments or {})
|
|
223
|
+
return self._save_query(job, result)
|
|
224
|
+
digest = hashlib.sha256(
|
|
225
|
+
json.dumps([job, operation, arguments or {}], sort_keys=True).encode()
|
|
226
|
+
).hexdigest()
|
|
227
|
+
keys = self.state.setdefault("query_keys", {})
|
|
228
|
+
key = keys.setdefault(digest, uuid.uuid4().hex)
|
|
229
|
+
self.workspace.store(self.state)
|
|
230
|
+
result = self.client.query(job, operation, arguments or {}, key)
|
|
231
|
+
offset = result.get("pagination", {}).get("next_offset")
|
|
232
|
+
|
|
233
|
+
def merge(left, right):
|
|
234
|
+
for field, value in right.items():
|
|
235
|
+
if isinstance(value, list):
|
|
236
|
+
left.setdefault(field, []).extend(value)
|
|
237
|
+
elif isinstance(value, dict):
|
|
238
|
+
merge(left.setdefault(field, {}), value)
|
|
239
|
+
|
|
240
|
+
while offset is not None:
|
|
241
|
+
page = self.client.query(
|
|
242
|
+
job, operation, arguments or {}, key, offset=offset
|
|
243
|
+
)
|
|
244
|
+
merge(result["details"], page["details"])
|
|
245
|
+
next_offset = page.get("pagination", {}).get("next_offset")
|
|
246
|
+
if next_offset is not None and next_offset <= offset:
|
|
247
|
+
raise WorkspaceError("Invalid findings pagination")
|
|
248
|
+
offset = next_offset
|
|
249
|
+
result["pagination"] = {"complete": True}
|
|
250
|
+
return self._save_query(job, result)
|
|
251
|
+
|
|
252
|
+
def _save_query(self, job, result):
|
|
253
|
+
config = self.state["jobs"].get(job, {}).get("configuration_id")
|
|
254
|
+
source = self.state["configurations"].get(config, {}).get("path")
|
|
255
|
+
parent = (
|
|
256
|
+
self.workspace._inside((self.workspace.root / source).parent)
|
|
257
|
+
if source
|
|
258
|
+
else self.workspace.root
|
|
259
|
+
)
|
|
260
|
+
# PostgreSQL JSONB may reorder keys on an idempotent replay. Canonical
|
|
261
|
+
# bytes keep repeat downloads stable while preserving older artifacts.
|
|
262
|
+
content = json.dumps(result, indent=2, default=str, sort_keys=True).encode()
|
|
263
|
+
digest = hashlib.sha256(content).hexdigest()
|
|
264
|
+
output = Workspace(parent)
|
|
265
|
+
query_id = Client.identifier(result["query_id"])
|
|
266
|
+
result["saved"] = [
|
|
267
|
+
str(output.save_artifact(job, query_id + "." + digest[:12] + "." + ext, content, digest))
|
|
268
|
+
for ext in ("json", "txt")
|
|
269
|
+
]
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
def download(self):
|
|
273
|
+
job = self.state.get("active_job")
|
|
274
|
+
if not job:
|
|
275
|
+
raise WorkspaceError("Select a job first")
|
|
276
|
+
config = self.state["jobs"].get(job, {}).get("configuration_id")
|
|
277
|
+
metadata = self.state["configurations"].get(config, {})
|
|
278
|
+
parent = (
|
|
279
|
+
(self.workspace.root / metadata.get("path", ".")).parent
|
|
280
|
+
if metadata.get("path")
|
|
281
|
+
else self.workspace.root
|
|
282
|
+
)
|
|
283
|
+
parent = self.workspace._inside(parent)
|
|
284
|
+
if self.external:
|
|
285
|
+
paths = download_external(self.client, job, Workspace(parent))
|
|
286
|
+
else:
|
|
287
|
+
paths = fetch_artifacts(self.client, job, Workspace(parent))
|
|
288
|
+
return {"job_id": job, "saved": [str(path) for path in paths]}
|
|
289
|
+
|
|
290
|
+
def target(self):
|
|
291
|
+
job = self.state.get("active_job")
|
|
292
|
+
if not job:
|
|
293
|
+
raise WorkspaceError("Select a completed conversion first")
|
|
294
|
+
if self.external:
|
|
295
|
+
return self._target_local(job)
|
|
296
|
+
result = self.client.target(job)
|
|
297
|
+
source = self.state["jobs"].get(job, {}).get("configuration_id")
|
|
298
|
+
self.state["configurations"][result["configuration_id"]] = {
|
|
299
|
+
"parent_job_id": job,
|
|
300
|
+
"display_name": "Generated " + result["vendor"] + " · " + job,
|
|
301
|
+
"vendor": result["vendor"],
|
|
302
|
+
"path": self.state["configurations"].get(source, {}).get("path", ""),
|
|
303
|
+
"sha256": result["source_sha256"],
|
|
304
|
+
}
|
|
305
|
+
self.state["selected"] = result["configuration_id"]
|
|
306
|
+
self.state.pop("active_job", None)
|
|
307
|
+
self.workspace.store(self.state)
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
def interpret(self, text):
|
|
311
|
+
if not self.provider:
|
|
312
|
+
raise WorkspaceError("No model connected. Use /help for guided commands.")
|
|
313
|
+
return self.provider.select(text)
|
|
314
|
+
|
|
315
|
+
def _target_local(self, job):
|
|
316
|
+
"""Full API: save the translated config beside the source, then select it."""
|
|
317
|
+
status = self.client.job(job)
|
|
318
|
+
target = status.get("target_vendor")
|
|
319
|
+
translated = self.client.translated(job)
|
|
320
|
+
if not translated or not target or target == "n/a":
|
|
321
|
+
raise WorkspaceError("This job has no generated configuration yet")
|
|
322
|
+
config = self.state["jobs"].get(job, {}).get("configuration_id")
|
|
323
|
+
source = self.state["configurations"].get(config, {}).get("path")
|
|
324
|
+
parent = self.workspace._inside((self.workspace.root / source).parent) if source else self.workspace.root
|
|
325
|
+
saved = Workspace(parent).save_artifact(job, "translated.txt", translated,
|
|
326
|
+
hashlib.sha256(translated).hexdigest())
|
|
327
|
+
result = self._select_local(str(saved.relative_to(self.workspace.root)), target)
|
|
328
|
+
self.state["configurations"][result["configuration_id"]].update(
|
|
329
|
+
parent_job_id=job, display_name=f"Generated {target} · {job}")
|
|
330
|
+
self.workspace.store(self.state)
|
|
331
|
+
return {**result, "parent_job_id": job}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Credentials never enter workspace files or model messages."""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
|
|
8
|
+
SERVICE = "NetConverter Terminal"
|
|
9
|
+
# Only operating-system credential stores are trusted. Third-party keyring
|
|
10
|
+
# backends (plaintext files, encrypted-file backends with a master password on
|
|
11
|
+
# disk) are refused; there is no fallback secrets file.
|
|
12
|
+
_OS_BACKENDS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"keyring.backends.macOS",
|
|
15
|
+
"keyring.backends.Windows",
|
|
16
|
+
"keyring.backends.SecretService",
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _os_backend():
|
|
22
|
+
backend = keyring.get_keyring()
|
|
23
|
+
if backend.__class__.__module__ not in _OS_BACKENDS:
|
|
24
|
+
return None
|
|
25
|
+
return backend
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def keyring_available() -> bool:
|
|
29
|
+
try:
|
|
30
|
+
return _os_backend() is not None
|
|
31
|
+
except Exception:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_secret(name: str, *, prompt: bool = True) -> str:
|
|
36
|
+
value = os.environ.get(
|
|
37
|
+
"NETCONVERTER_API_KEY"
|
|
38
|
+
if name == "server" or name.startswith("server:")
|
|
39
|
+
else f"NETCONVERTER_{name.upper()}_API_KEY"
|
|
40
|
+
)
|
|
41
|
+
if value:
|
|
42
|
+
return value
|
|
43
|
+
try:
|
|
44
|
+
backend = _os_backend()
|
|
45
|
+
if backend is None:
|
|
46
|
+
return (
|
|
47
|
+
getpass.getpass(f"{name} API key (hidden, memory only): ").strip()
|
|
48
|
+
if prompt
|
|
49
|
+
else ""
|
|
50
|
+
)
|
|
51
|
+
value = backend.get_password(SERVICE, name)
|
|
52
|
+
except Exception:
|
|
53
|
+
value = None
|
|
54
|
+
if value:
|
|
55
|
+
return value
|
|
56
|
+
if not prompt:
|
|
57
|
+
return ""
|
|
58
|
+
return getpass.getpass(f"{name} API key (hidden, memory only): ").strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_secret(name: str, value: str) -> bool:
|
|
62
|
+
try:
|
|
63
|
+
backend = _os_backend()
|
|
64
|
+
if backend is None:
|
|
65
|
+
return False
|
|
66
|
+
backend.set_password(SERVICE, name, value)
|
|
67
|
+
return True
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_secret(name: str) -> str:
|
|
73
|
+
"""Read a stored secret without prompting and without environment overrides."""
|
|
74
|
+
try:
|
|
75
|
+
backend = _os_backend()
|
|
76
|
+
if backend is None:
|
|
77
|
+
return ""
|
|
78
|
+
return backend.get_password(SERVICE, name) or ""
|
|
79
|
+
except Exception:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def delete_secret(name: str) -> bool:
|
|
84
|
+
"""Remove a stored secret; True when nothing remains stored under ``name``."""
|
|
85
|
+
try:
|
|
86
|
+
backend = _os_backend()
|
|
87
|
+
if backend is None:
|
|
88
|
+
return False
|
|
89
|
+
if backend.get_password(SERVICE, name) is None:
|
|
90
|
+
return True
|
|
91
|
+
backend.delete_password(SERVICE, name)
|
|
92
|
+
return True
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|