qso-graph-auth 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.
- qso_graph_auth/__init__.py +3 -0
- qso_graph_auth/cli/__init__.py +7 -0
- qso_graph_auth/cli/creds.py +234 -0
- qso_graph_auth/cli/persona.py +204 -0
- qso_graph_auth/cli/provider.py +159 -0
- qso_graph_auth/cli/root.py +52 -0
- qso_graph_auth/credentials/__init__.py +19 -0
- qso_graph_auth/credentials/credentials.py +97 -0
- qso_graph_auth/identity/__init__.py +19 -0
- qso_graph_auth/identity/errors.py +24 -0
- qso_graph_auth/identity/manager.py +78 -0
- qso_graph_auth/identity/models.py +61 -0
- qso_graph_auth/identity/store.py +180 -0
- qso_graph_auth/paths.py +48 -0
- qso_graph_auth/providers/__init__.py +9 -0
- qso_graph_auth/providers/adapters.py +68 -0
- qso_graph_auth/resources/__init__.py +0 -0
- qso_graph_auth/resources/providers/eqsl.json +19 -0
- qso_graph_auth/resources/providers/hamqth.json +12 -0
- qso_graph_auth/resources/providers/lotw.json +19 -0
- qso_graph_auth/resources/providers/qrz.json +17 -0
- qso_graph_auth/resources/providers/qrz_logbook.json +17 -0
- qso_graph_auth/resources/schemas/manifest.v1.json +46 -0
- qso_graph_auth-0.1.0.dist-info/METADATA +115 -0
- qso_graph_auth-0.1.0.dist-info/RECORD +28 -0
- qso_graph_auth-0.1.0.dist-info/WHEEL +4 -0
- qso_graph_auth-0.1.0.dist-info/entry_points.txt +2 -0
- qso_graph_auth-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Credentials subcommands (OS keyring)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import getpass
|
|
7
|
+
import json
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from qso_graph_auth.cli.provider import auth_type, list_supported
|
|
11
|
+
from qso_graph_auth.credentials import (
|
|
12
|
+
SERVICE,
|
|
13
|
+
Credentials,
|
|
14
|
+
delete_creds,
|
|
15
|
+
get_creds,
|
|
16
|
+
set_creds,
|
|
17
|
+
)
|
|
18
|
+
from qso_graph_auth.identity.store import PersonaStore
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import keyring
|
|
22
|
+
except Exception:
|
|
23
|
+
keyring = None # type: ignore[assignment]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _redacted(c: Credentials) -> dict[str, str]:
|
|
27
|
+
def mask(v: str) -> str:
|
|
28
|
+
return (v[:2] + "…") if len(v) > 4 else "•••"
|
|
29
|
+
|
|
30
|
+
out: dict[str, str] = {}
|
|
31
|
+
if c.username:
|
|
32
|
+
out["username"] = c.username
|
|
33
|
+
if c.password:
|
|
34
|
+
out["password"] = mask(c.password)
|
|
35
|
+
if c.api_key:
|
|
36
|
+
out["api_key"] = mask(c.api_key)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_set(args: argparse.Namespace) -> int:
|
|
41
|
+
if keyring is None:
|
|
42
|
+
print("Install keyring: pip install keyring")
|
|
43
|
+
return 2
|
|
44
|
+
|
|
45
|
+
prov = args.provider.lower()
|
|
46
|
+
a = auth_type(prov)
|
|
47
|
+
|
|
48
|
+
username: str | None = args.username
|
|
49
|
+
password: str | None = args.password
|
|
50
|
+
api_key: str | None = args.api_key
|
|
51
|
+
|
|
52
|
+
def _norm(s: str | None) -> str | None:
|
|
53
|
+
if s is None:
|
|
54
|
+
return None
|
|
55
|
+
s = s.strip()
|
|
56
|
+
return s if s else None
|
|
57
|
+
|
|
58
|
+
if a == "username_password":
|
|
59
|
+
username = _norm(username) or input(f"{prov} username/callsign: ").strip() or None
|
|
60
|
+
if password is None:
|
|
61
|
+
pw = getpass.getpass(f"{prov} password: ").strip()
|
|
62
|
+
password = pw or None
|
|
63
|
+
api_key = None
|
|
64
|
+
elif a == "api_key":
|
|
65
|
+
username = _norm(username) or input(f"{prov} username/callsign: ").strip() or None
|
|
66
|
+
api_key = _norm(api_key) or input(f"{prov} API key: ").strip() or None
|
|
67
|
+
password = None
|
|
68
|
+
else:
|
|
69
|
+
username = _norm(username) or input("Username (blank to skip): ").strip() or None
|
|
70
|
+
api_key = _norm(api_key) or input("API key (blank to skip): ").strip() or None
|
|
71
|
+
if password is None:
|
|
72
|
+
pw = getpass.getpass("Password (hidden, blank to skip): ").strip()
|
|
73
|
+
password = pw or None
|
|
74
|
+
|
|
75
|
+
creds = Credentials(
|
|
76
|
+
username=_norm(username), password=_norm(password), api_key=_norm(api_key)
|
|
77
|
+
)
|
|
78
|
+
set_creds(args.persona, prov, creds)
|
|
79
|
+
print(f"Saved credentials for {args.persona}:{prov} in {SERVICE}.")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_get(args: argparse.Namespace) -> int:
|
|
84
|
+
if keyring is None:
|
|
85
|
+
print("Install keyring: pip install keyring")
|
|
86
|
+
return 2
|
|
87
|
+
c = get_creds(args.persona, args.provider)
|
|
88
|
+
if not c:
|
|
89
|
+
print("No credentials stored.")
|
|
90
|
+
return 1
|
|
91
|
+
if getattr(args, "raw", False):
|
|
92
|
+
print(c.to_json())
|
|
93
|
+
else:
|
|
94
|
+
print(json.dumps(_redacted(c), indent=2))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def cmd_delete(args: argparse.Namespace) -> int:
|
|
99
|
+
if keyring is None:
|
|
100
|
+
print("Install keyring: pip install keyring")
|
|
101
|
+
return 2
|
|
102
|
+
ok = delete_creds(args.persona, args.provider)
|
|
103
|
+
print("Deleted." if ok else "Nothing to delete.")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_reset(args: argparse.Namespace) -> int:
|
|
108
|
+
if not args.yes:
|
|
109
|
+
print("Refusing to reset without --yes (this deletes ALL personas and credentials)")
|
|
110
|
+
return 2
|
|
111
|
+
if keyring is None:
|
|
112
|
+
print("Install keyring: pip install keyring")
|
|
113
|
+
return 2
|
|
114
|
+
|
|
115
|
+
store = PersonaStore()
|
|
116
|
+
personas = store.list()
|
|
117
|
+
cred_count = 0
|
|
118
|
+
|
|
119
|
+
for p in personas:
|
|
120
|
+
for prov in list(p.providers.keys()):
|
|
121
|
+
if delete_creds(p.name, prov):
|
|
122
|
+
cred_count += 1
|
|
123
|
+
|
|
124
|
+
persona_count = 0
|
|
125
|
+
for p in personas:
|
|
126
|
+
store.remove(p.name)
|
|
127
|
+
persona_count += 1
|
|
128
|
+
|
|
129
|
+
print(f"Reset complete: {persona_count} persona(s), {cred_count} credential(s) deleted.")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
134
|
+
if keyring is None:
|
|
135
|
+
print("Install keyring: pip install keyring")
|
|
136
|
+
return 2
|
|
137
|
+
print(
|
|
138
|
+
"Keyring entries are stored under service "
|
|
139
|
+
f"'{SERVICE}' with username 'persona:provider'.\n"
|
|
140
|
+
"Use your OS keychain UI to enumerate entries."
|
|
141
|
+
)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _has_required_fields(c: Optional[Credentials], a: str) -> bool:
|
|
146
|
+
a = a.lower()
|
|
147
|
+
if a == "username_password":
|
|
148
|
+
return bool(c and c.username and c.password)
|
|
149
|
+
if a == "api_key":
|
|
150
|
+
return bool(c and c.username and c.api_key)
|
|
151
|
+
return bool(c and (c.username or c.password or c.api_key))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
155
|
+
store = PersonaStore()
|
|
156
|
+
personas = store.list()
|
|
157
|
+
if not personas:
|
|
158
|
+
print("(no personas) — create one with: qso-auth persona add --name NAME …")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
target = (args.persona or "").lower() if hasattr(args, "persona") else ""
|
|
162
|
+
ok = missing = 0
|
|
163
|
+
|
|
164
|
+
for p in personas:
|
|
165
|
+
if not p.name or (target and p.name.lower() != target):
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
for prov in list_supported():
|
|
169
|
+
if prov not in p.providers:
|
|
170
|
+
continue
|
|
171
|
+
a = auth_type(prov)
|
|
172
|
+
c = get_creds(p.name, prov)
|
|
173
|
+
if _has_required_fields(c, a):
|
|
174
|
+
print(f" {p.name}:{prov} — stored ({a})")
|
|
175
|
+
ok += 1
|
|
176
|
+
else:
|
|
177
|
+
need = (
|
|
178
|
+
"username+password"
|
|
179
|
+
if a == "username_password"
|
|
180
|
+
else (
|
|
181
|
+
"username+api_key" if a == "api_key" else "some secret"
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
print(
|
|
185
|
+
f" {p.name}:{prov} — missing {need} "
|
|
186
|
+
f"(run: qso-auth creds set {p.name} {prov})"
|
|
187
|
+
)
|
|
188
|
+
missing += 1
|
|
189
|
+
|
|
190
|
+
print(f"\nSummary: stored={ok}, missing={missing}")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def register_cli(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
195
|
+
p = subparsers.add_parser(
|
|
196
|
+
"creds",
|
|
197
|
+
help="Manage credentials in the OS keyring.",
|
|
198
|
+
description="Set/get/delete credentials per persona/provider.",
|
|
199
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
sp: argparse._SubParsersAction[argparse.ArgumentParser] = p.add_subparsers(
|
|
203
|
+
dest="creds_cmd", required=True
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
s = sp.add_parser("set", help="Save credentials.")
|
|
207
|
+
s.add_argument("persona", help="Persona name")
|
|
208
|
+
s.add_argument("provider", help="Provider (eqsl, lotw, qrz, qrz_logbook, hamqth)")
|
|
209
|
+
s.add_argument("--username", default=None, help="Username/login")
|
|
210
|
+
s.add_argument("--password", default=None, help="Password (unsafe on CLI)")
|
|
211
|
+
s.add_argument("--api-key", default=None, help="API key/token")
|
|
212
|
+
s.set_defaults(func=cmd_set)
|
|
213
|
+
|
|
214
|
+
g = sp.add_parser("get", help="Show redacted credentials.")
|
|
215
|
+
g.add_argument("persona", help="Persona name")
|
|
216
|
+
g.add_argument("provider", help="Provider")
|
|
217
|
+
g.add_argument("--raw", action="store_true", help="Print raw JSON (Use with Caution)")
|
|
218
|
+
g.set_defaults(func=cmd_get)
|
|
219
|
+
|
|
220
|
+
d = sp.add_parser("delete", help="Delete stored credentials.")
|
|
221
|
+
d.add_argument("persona", help="Persona name")
|
|
222
|
+
d.add_argument("provider", help="Provider")
|
|
223
|
+
d.set_defaults(func=cmd_delete)
|
|
224
|
+
|
|
225
|
+
list_parser = sp.add_parser("list", help="Explain where entries live.")
|
|
226
|
+
list_parser.set_defaults(func=cmd_list)
|
|
227
|
+
|
|
228
|
+
r = sp.add_parser("reset", help="Delete ALL personas and credentials (factory reset).")
|
|
229
|
+
r.add_argument("--yes", action="store_true", help="Confirm destructive op")
|
|
230
|
+
r.set_defaults(func=cmd_reset)
|
|
231
|
+
|
|
232
|
+
doc = sp.add_parser("doctor", help="Check creds across personas/providers.")
|
|
233
|
+
doc.add_argument("--persona", default=None, help="Only check this persona")
|
|
234
|
+
doc.set_defaults(func=cmd_doctor)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Persona subcommands backed by PersonaStore (JSON index)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import date
|
|
7
|
+
|
|
8
|
+
from qso_graph_auth.credentials import Credentials, set_creds
|
|
9
|
+
from qso_graph_auth.identity.store import PersonaStore
|
|
10
|
+
from qso_graph_auth.paths import config_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _validate_dates(
|
|
14
|
+
start: str | None, end: str | None,
|
|
15
|
+
) -> tuple[date | None, date | None]:
|
|
16
|
+
if not start:
|
|
17
|
+
raise SystemExit("--start YYYY-MM-DD is required")
|
|
18
|
+
s = start.strip()
|
|
19
|
+
try:
|
|
20
|
+
ds = date.fromisoformat(s)
|
|
21
|
+
except Exception as exc:
|
|
22
|
+
raise SystemExit(f"invalid --start '{start}'") from exc
|
|
23
|
+
de: date | None = None
|
|
24
|
+
if end:
|
|
25
|
+
e = end.strip()
|
|
26
|
+
try:
|
|
27
|
+
de = date.fromisoformat(e)
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
raise SystemExit(f"invalid --end '{end}'") from exc
|
|
30
|
+
if de < ds:
|
|
31
|
+
raise SystemExit("end date must be >= start date")
|
|
32
|
+
return ds, de
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
36
|
+
store = PersonaStore()
|
|
37
|
+
personas = store.list()
|
|
38
|
+
if not personas:
|
|
39
|
+
print("(no personas) — create with: qso-auth persona add --name NAME …")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
verbose = bool(getattr(args, "verbose", False))
|
|
43
|
+
for p in personas:
|
|
44
|
+
providers = ", ".join(sorted(p.providers.keys())) or "(none)"
|
|
45
|
+
if verbose:
|
|
46
|
+
print(
|
|
47
|
+
f"{p.name:15s} {p.callsign:10s} "
|
|
48
|
+
f"{p.active_span():23s} providers: {providers}"
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
print(f"{p.name:15s} providers: {providers}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_add(args: argparse.Namespace) -> int:
|
|
56
|
+
name = args.name.strip().lower()
|
|
57
|
+
if not name:
|
|
58
|
+
print("persona --name is required")
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
callsign = (args.callsign or "").strip().upper()
|
|
62
|
+
if not callsign:
|
|
63
|
+
raise SystemExit("callsign is required")
|
|
64
|
+
|
|
65
|
+
ds, de = _validate_dates(args.start, args.end)
|
|
66
|
+
|
|
67
|
+
store = PersonaStore()
|
|
68
|
+
existing = store.get(name)
|
|
69
|
+
if existing and not args.force:
|
|
70
|
+
print(f"Persona {name} already exists. Use --force to overwrite.")
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
store.upsert(name=name, callsign=callsign, start=ds, end=de)
|
|
74
|
+
|
|
75
|
+
providers = [str(p).lower() for p in (args.providers or [])]
|
|
76
|
+
for prov in providers:
|
|
77
|
+
store.set_provider_ref(persona=name, provider=prov, username="")
|
|
78
|
+
|
|
79
|
+
print(f"Created persona {name}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_remove(args: argparse.Namespace) -> int:
|
|
84
|
+
store = PersonaStore()
|
|
85
|
+
if not store.remove(args.name):
|
|
86
|
+
print(f"No persona {args.name}")
|
|
87
|
+
return 1
|
|
88
|
+
print(f"Removed persona {args.name}")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_remove_all(args: argparse.Namespace) -> int:
|
|
93
|
+
if not args.yes:
|
|
94
|
+
print("Refusing to remove all personas without --yes")
|
|
95
|
+
return 2
|
|
96
|
+
store = PersonaStore()
|
|
97
|
+
personas = store.list()
|
|
98
|
+
if not personas:
|
|
99
|
+
print("(no personas)")
|
|
100
|
+
return 0
|
|
101
|
+
count = 0
|
|
102
|
+
for p in personas:
|
|
103
|
+
store.remove(p.name)
|
|
104
|
+
count += 1
|
|
105
|
+
print(f"Removed {count} persona(s)")
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
110
|
+
store = PersonaStore()
|
|
111
|
+
p = store.get(args.name)
|
|
112
|
+
if not p:
|
|
113
|
+
print(f"No persona {args.name} found.")
|
|
114
|
+
return 1
|
|
115
|
+
print(f"name: {p.name}")
|
|
116
|
+
print(f"callsign: {p.callsign}")
|
|
117
|
+
print(f"start: {p.start.isoformat() if p.start else '—'}")
|
|
118
|
+
print(f"end: {p.end.isoformat() if p.end else '—'}")
|
|
119
|
+
if p.providers:
|
|
120
|
+
print(f"providers: {', '.join(sorted(p.providers.keys()))}")
|
|
121
|
+
else:
|
|
122
|
+
print("providers: (none)")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_set_active(args: argparse.Namespace) -> int:
|
|
127
|
+
cfg = config_dir()
|
|
128
|
+
marker = cfg / "current.txt"
|
|
129
|
+
marker.write_text(args.name, encoding="utf-8")
|
|
130
|
+
print(f"Set active persona to {args.name}")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_set_credential(args: argparse.Namespace) -> int:
|
|
135
|
+
name = args.persona
|
|
136
|
+
provider = args.provider.lower()
|
|
137
|
+
|
|
138
|
+
def _norm(s: str | None) -> str | None:
|
|
139
|
+
if s is None:
|
|
140
|
+
return None
|
|
141
|
+
s = s.strip()
|
|
142
|
+
return s if s else None
|
|
143
|
+
|
|
144
|
+
creds = Credentials(
|
|
145
|
+
username=_norm(args.username),
|
|
146
|
+
password=_norm(args.password),
|
|
147
|
+
api_key=_norm(args.api_key),
|
|
148
|
+
)
|
|
149
|
+
set_creds(name, provider, creds)
|
|
150
|
+
print(f"Saved credentials for {name}:{provider}.")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def register_cli(
|
|
155
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
156
|
+
) -> None:
|
|
157
|
+
p = subparsers.add_parser(
|
|
158
|
+
"persona",
|
|
159
|
+
help="Manage personas",
|
|
160
|
+
description="Manage persona profiles with callsign date ranges.",
|
|
161
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
162
|
+
)
|
|
163
|
+
sp = p.add_subparsers(dest="persona_cmd", required=True)
|
|
164
|
+
|
|
165
|
+
s_list = sp.add_parser("list", help="List personas")
|
|
166
|
+
s_list.add_argument("--verbose", action="store_true", help="Show callsign and date range")
|
|
167
|
+
s_list.set_defaults(func=cmd_list)
|
|
168
|
+
|
|
169
|
+
s_add = sp.add_parser("add", help="Add a new persona")
|
|
170
|
+
s_add.add_argument("--name", required=True, help="Persona name")
|
|
171
|
+
s_add.add_argument("--callsign", required=True, help="Primary callsign")
|
|
172
|
+
s_add.add_argument("--start", required=True, help="Start date YYYY-MM-DD (inclusive)")
|
|
173
|
+
s_add.add_argument("--end", help="End date YYYY-MM-DD (inclusive)")
|
|
174
|
+
s_add.add_argument("--providers", nargs="*", help="Initial providers (e.g. eqsl lotw qrz)")
|
|
175
|
+
s_add.add_argument("--force", action="store_true", help="Overwrite if exists")
|
|
176
|
+
s_add.set_defaults(func=cmd_add)
|
|
177
|
+
|
|
178
|
+
s_rm = sp.add_parser("remove", help="Remove a persona")
|
|
179
|
+
s_rm.add_argument("name", help="Persona name")
|
|
180
|
+
s_rm.set_defaults(func=cmd_remove)
|
|
181
|
+
|
|
182
|
+
s_rmall = sp.add_parser("remove-all", help="Remove ALL personas")
|
|
183
|
+
s_rmall.add_argument("--yes", action="store_true", help="Confirm destructive op")
|
|
184
|
+
s_rmall.set_defaults(func=cmd_remove_all)
|
|
185
|
+
|
|
186
|
+
s_show = sp.add_parser("show", help="Show persona config")
|
|
187
|
+
s_show.add_argument("name", help="Persona name")
|
|
188
|
+
s_show.set_defaults(func=cmd_show)
|
|
189
|
+
|
|
190
|
+
s_set = sp.add_parser("set-active", help="Mark persona as active")
|
|
191
|
+
s_set.add_argument("name", help="Persona name")
|
|
192
|
+
s_set.set_defaults(func=cmd_set_active)
|
|
193
|
+
|
|
194
|
+
s_sc = sp.add_parser("set-credential", help="Set credentials for a persona/provider")
|
|
195
|
+
s_sc.add_argument("--persona", required=True, help="Persona name")
|
|
196
|
+
s_sc.add_argument(
|
|
197
|
+
"--provider", required=True,
|
|
198
|
+
choices=["eqsl", "lotw", "qrz", "qrz_logbook", "hamqth"],
|
|
199
|
+
help="Provider slug",
|
|
200
|
+
)
|
|
201
|
+
s_sc.add_argument("--username", help="Username/callsign", default=None)
|
|
202
|
+
s_sc.add_argument("--password", help="Password", default=None)
|
|
203
|
+
s_sc.add_argument("--api-key", help="API key/token", default=None)
|
|
204
|
+
s_sc.set_defaults(func=cmd_set_credential)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Provider subcommands and metadata access (dynamic discovery)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from functools import cache
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, cast
|
|
11
|
+
|
|
12
|
+
from qso_graph_auth.credentials import get_creds
|
|
13
|
+
from qso_graph_auth.identity.store import PersonaStore
|
|
14
|
+
|
|
15
|
+
# ---------- Provider metadata (resources/providers/*.json) ----------
|
|
16
|
+
|
|
17
|
+
PROVIDERS_DIR: Path = Path(files("qso_graph_auth.resources") / "providers") # type: ignore[arg-type]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _provider_path(slug: str) -> Path:
|
|
21
|
+
return Path(PROVIDERS_DIR) / f"{slug}.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def list_supported() -> List[str]:
|
|
25
|
+
p = Path(PROVIDERS_DIR)
|
|
26
|
+
if not p.exists():
|
|
27
|
+
return []
|
|
28
|
+
return sorted(f.stem for f in p.glob("*.json"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@cache
|
|
32
|
+
def get_provider(slug: str) -> Dict[str, Any]:
|
|
33
|
+
path = _provider_path(slug)
|
|
34
|
+
try:
|
|
35
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
36
|
+
data = json.load(fh)
|
|
37
|
+
return cast(Dict[str, Any], data or {})
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return {"name": slug, "slug": slug, "auth": "none"}
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
return {"name": slug, "slug": slug, "auth": "none"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def auth_type(slug: str) -> str:
|
|
45
|
+
meta = get_provider(slug)
|
|
46
|
+
return str(meta.get("auth", "none")).lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require_supported(provider: str) -> str:
|
|
50
|
+
p = provider.lower()
|
|
51
|
+
supported = set(list_supported())
|
|
52
|
+
if p not in supported:
|
|
53
|
+
raise SystemExit(
|
|
54
|
+
f"Unknown provider '{provider}'. Supported: {', '.join(sorted(supported))}"
|
|
55
|
+
)
|
|
56
|
+
return p
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
60
|
+
providers = list_supported()
|
|
61
|
+
print("Supported providers: " + ", ".join(providers or ["(none found)"]))
|
|
62
|
+
|
|
63
|
+
store = PersonaStore()
|
|
64
|
+
personas = store.list()
|
|
65
|
+
if not personas:
|
|
66
|
+
print("(no personas discovered)")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
print("\nEnabled per persona:")
|
|
70
|
+
for p in personas:
|
|
71
|
+
enabled = [k for k in sorted(p.providers.keys()) if k in providers]
|
|
72
|
+
print(f" {p.name:15s} -> {', '.join(enabled) or '(none)'}")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_enable(args: argparse.Namespace) -> int:
|
|
77
|
+
persona = args.persona
|
|
78
|
+
provider = _require_supported(args.provider)
|
|
79
|
+
|
|
80
|
+
store = PersonaStore()
|
|
81
|
+
p = store.get(persona)
|
|
82
|
+
if not p:
|
|
83
|
+
print(f"Persona '{persona}' not found")
|
|
84
|
+
return 1
|
|
85
|
+
|
|
86
|
+
if provider in p.providers:
|
|
87
|
+
print(f"{provider} already enabled for {persona}")
|
|
88
|
+
else:
|
|
89
|
+
store.set_provider_ref(persona=persona, provider=provider, username="")
|
|
90
|
+
print(f"Enabled {provider} for {persona}")
|
|
91
|
+
|
|
92
|
+
c = get_creds(persona, provider)
|
|
93
|
+
a = auth_type(provider)
|
|
94
|
+
need = (
|
|
95
|
+
"username+password"
|
|
96
|
+
if a == "username_password"
|
|
97
|
+
else ("username+api_key" if a == "api_key" else "some secret")
|
|
98
|
+
)
|
|
99
|
+
missing = (
|
|
100
|
+
not c
|
|
101
|
+
or (a == "username_password" and not (c.username and c.password))
|
|
102
|
+
or (a == "api_key" and not (c.username and c.api_key))
|
|
103
|
+
)
|
|
104
|
+
if missing:
|
|
105
|
+
print(
|
|
106
|
+
f"note: no credentials for {persona}:{provider} ({a}). "
|
|
107
|
+
f"Run: qso-auth creds set {persona} {provider} # need {need}"
|
|
108
|
+
)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_disable(args: argparse.Namespace) -> int:
|
|
113
|
+
persona = args.persona
|
|
114
|
+
provider = _require_supported(args.provider)
|
|
115
|
+
|
|
116
|
+
store = PersonaStore()
|
|
117
|
+
p = store.get(persona)
|
|
118
|
+
if not p:
|
|
119
|
+
print(f"Persona '{persona}' not found")
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
removed = store.remove_provider_ref(persona=persona, provider=provider)
|
|
124
|
+
except KeyError:
|
|
125
|
+
print(f"Persona '{persona}' not found")
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
if not removed:
|
|
129
|
+
print(f"{provider} already disabled for {persona}")
|
|
130
|
+
else:
|
|
131
|
+
print(f"Disabled {provider} for {persona}")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def register_cli(
|
|
136
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
137
|
+
) -> None:
|
|
138
|
+
p = subparsers.add_parser(
|
|
139
|
+
"provider",
|
|
140
|
+
help="Provider operations",
|
|
141
|
+
description="Enable/disable providers on persona profiles.",
|
|
142
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
143
|
+
)
|
|
144
|
+
sp: argparse._SubParsersAction[argparse.ArgumentParser] = p.add_subparsers(
|
|
145
|
+
dest="provider_cmd", required=True
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
s_list = sp.add_parser("list", help="List supported and enabled providers.")
|
|
149
|
+
s_list.set_defaults(func=cmd_list)
|
|
150
|
+
|
|
151
|
+
s_en = sp.add_parser("enable", help="Enable a provider for a persona.")
|
|
152
|
+
s_en.add_argument("persona", help="Persona name")
|
|
153
|
+
s_en.add_argument("provider", help="Provider to enable")
|
|
154
|
+
s_en.set_defaults(func=cmd_enable)
|
|
155
|
+
|
|
156
|
+
s_dis = sp.add_parser("disable", help="Disable a provider for a persona.")
|
|
157
|
+
s_dis.add_argument("persona", help="Persona name")
|
|
158
|
+
s_dis.add_argument("provider", help="Provider to disable")
|
|
159
|
+
s_dis.set_defaults(func=cmd_disable)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Root CLI wiring for qso-auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Callable, Protocol, cast
|
|
8
|
+
|
|
9
|
+
from . import creds, persona, provider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _RegisterCLI(Protocol):
|
|
13
|
+
def __call__(self, sp: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="qso-auth",
|
|
19
|
+
description="Persona and credential management for qso-graph MCP servers",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--version", action="version",
|
|
23
|
+
version=f"%(prog)s {__import__('qso_graph_auth').__version__}",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
27
|
+
|
|
28
|
+
if hasattr(persona, "register_cli"):
|
|
29
|
+
cast(_RegisterCLI, getattr(persona, "register_cli"))(subparsers)
|
|
30
|
+
if hasattr(provider, "register_cli"):
|
|
31
|
+
cast(_RegisterCLI, getattr(provider, "register_cli"))(subparsers)
|
|
32
|
+
if hasattr(creds, "register_cli"):
|
|
33
|
+
cast(_RegisterCLI, getattr(creds, "register_cli"))(subparsers)
|
|
34
|
+
|
|
35
|
+
return parser
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
args_in = sys.argv[1:] if argv is None else argv
|
|
40
|
+
parser = build_parser()
|
|
41
|
+
|
|
42
|
+
if not args_in:
|
|
43
|
+
parser.print_help()
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
args = parser.parse_args(args_in)
|
|
47
|
+
func = cast(Callable[[argparse.Namespace], int] | None, getattr(args, "func", None))
|
|
48
|
+
if func is not None:
|
|
49
|
+
return func(args)
|
|
50
|
+
|
|
51
|
+
parser.print_help()
|
|
52
|
+
return 2
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Credentials package for qso-graph-auth."""
|
|
2
|
+
|
|
3
|
+
from .credentials import (
|
|
4
|
+
SERVICE,
|
|
5
|
+
Credentials,
|
|
6
|
+
delete_creds,
|
|
7
|
+
ensure_keyring,
|
|
8
|
+
get_creds,
|
|
9
|
+
set_creds,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"SERVICE",
|
|
14
|
+
"Credentials",
|
|
15
|
+
"delete_creds",
|
|
16
|
+
"ensure_keyring",
|
|
17
|
+
"get_creds",
|
|
18
|
+
"set_creds",
|
|
19
|
+
]
|