recala-cli 0.6.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.
- recala_cli/__init__.py +3 -0
- recala_cli/__main__.py +3 -0
- recala_cli/article_runs.py +372 -0
- recala_cli/auth.py +196 -0
- recala_cli/client.py +103 -0
- recala_cli/commands/__init__.py +1 -0
- recala_cli/commands/article.py +835 -0
- recala_cli/commands/auth.py +207 -0
- recala_cli/commands/billing.py +76 -0
- recala_cli/commands/bing.py +164 -0
- recala_cli/commands/cadence.py +217 -0
- recala_cli/commands/connect.py +395 -0
- recala_cli/commands/credits.py +46 -0
- recala_cli/commands/faithful_translations.py +70 -0
- recala_cli/commands/gsc.py +79 -0
- recala_cli/commands/index.py +293 -0
- recala_cli/commands/keys.py +98 -0
- recala_cli/commands/knowledge.py +245 -0
- recala_cli/commands/languages.py +119 -0
- recala_cli/commands/monthly_report.py +152 -0
- recala_cli/commands/notifications.py +106 -0
- recala_cli/commands/onboard.py +237 -0
- recala_cli/commands/org.py +352 -0
- recala_cli/commands/profile.py +70 -0
- recala_cli/commands/questions.py +246 -0
- recala_cli/commands/reads.py +320 -0
- recala_cli/commands/refresh.py +138 -0
- recala_cli/commands/refusals.py +20 -0
- recala_cli/commands/schema.py +47 -0
- recala_cli/commands/shell.py +81 -0
- recala_cli/commands/skills.py +92 -0
- recala_cli/commands/slot_topic.py +94 -0
- recala_cli/commands/status.py +153 -0
- recala_cli/commands/topics.py +328 -0
- recala_cli/config.py +176 -0
- recala_cli/confirm.py +137 -0
- recala_cli/display.py +40 -0
- recala_cli/endpoints.py +258 -0
- recala_cli/errors.py +220 -0
- recala_cli/human.py +324 -0
- recala_cli/ids.py +139 -0
- recala_cli/labels.py +74 -0
- recala_cli/main.py +129 -0
- recala_cli/onboarding.py +522 -0
- recala_cli/organizations.py +197 -0
- recala_cli/output.py +34 -0
- recala_cli/parallel.py +18 -0
- recala_cli/polling.py +102 -0
- recala_cli/prompts.py +88 -0
- recala_cli/runtime.py +216 -0
- recala_cli/search_numbers.py +22 -0
- recala_cli/skills/recala-apply-revision-notes/SKILL.md +39 -0
- recala_cli/skills/recala-article-signoff/SKILL.md +67 -0
- recala_cli/skills/recala-cli-contract.md +78 -0
- recala_cli/skills/recala-connect-search-console/SKILL.md +29 -0
- recala_cli/skills/recala-onboard/SKILL.md +25 -0
- recala_cli/skills/recala-onboard-brand/SKILL.md +25 -0
- recala_cli/skills/recala-plan-month/SKILL.md +33 -0
- recala_cli/skills/recala-schedule-approved/SKILL.md +36 -0
- recala_cli/skills/recala-weekly-authority-report/SKILL.md +35 -0
- recala_cli/ui.py +260 -0
- recala_cli/updates.py +43 -0
- recala_cli/views.py +372 -0
- recala_cli-0.6.0.dist-info/METADATA +63 -0
- recala_cli-0.6.0.dist-info/RECORD +67 -0
- recala_cli-0.6.0.dist-info/WHEEL +4 -0
- recala_cli-0.6.0.dist-info/entry_points.txt +2 -0
recala_cli/__init__.py
ADDED
recala_cli/__main__.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Short status reads and actionable outcomes for article work that outlives a request."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import UTC, datetime, timedelta
|
|
5
|
+
from shlex import quote
|
|
6
|
+
|
|
7
|
+
from recala_cli.display import display_name
|
|
8
|
+
from recala_cli.endpoints import ARTICLE_SHOW, GENERATION_STATUS, CADENCE_SHOW
|
|
9
|
+
from recala_cli.errors import CliError, ExitCode
|
|
10
|
+
from recala_cli.human import describe_fields, finding_sentences, recheck_findings
|
|
11
|
+
from recala_cli.polling import poll_until
|
|
12
|
+
from recala_cli.views import company_timezone, moment_in
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hold_text(hold, article_id=None, *, ctx=None):
|
|
16
|
+
"""Render the C1 explanation and its one supported recovery path for a person."""
|
|
17
|
+
if not hold:
|
|
18
|
+
return ""
|
|
19
|
+
identifier = str(article_id or "")[:8]
|
|
20
|
+
if hold.get("recognised") is False:
|
|
21
|
+
return ("Held for a reason Recala cannot explain yet. "
|
|
22
|
+
f"Contact support with the article id {identifier}.")
|
|
23
|
+
explanation = display_name(hold.get("explanation"))
|
|
24
|
+
lines = [f"Held: {display_name(hold.get('title')) or 'The article needs attention'}"]
|
|
25
|
+
if explanation:
|
|
26
|
+
lines.append(explanation)
|
|
27
|
+
detail = str(hold.get("detail") or "").strip()
|
|
28
|
+
if detail and display_name(detail) != explanation:
|
|
29
|
+
lines.append("Why:")
|
|
30
|
+
for index, text in enumerate(_hold_findings(hold.get("code") or "", detail), 1):
|
|
31
|
+
lines.append(f"{index}. {text}")
|
|
32
|
+
codes = list(dict.fromkeys(hold.get("codes") or []))
|
|
33
|
+
primary = hold.get("code") or (codes[0] if codes else None)
|
|
34
|
+
others = [code.replace("_", " ").lower() for code in codes if code != primary]
|
|
35
|
+
if others:
|
|
36
|
+
lines.append("Also held for: " + ", ".join(others))
|
|
37
|
+
recheck = hold.get("recheck")
|
|
38
|
+
if recheck is not None:
|
|
39
|
+
zone = company_timezone(ctx, hold) if ctx is not None else hold.get("timezone")
|
|
40
|
+
when = moment_in(recheck.get("at"), zone) or "date not recorded"
|
|
41
|
+
verdict = "passed" if recheck.get("cleared") else "still blocked"
|
|
42
|
+
lines.append(f"Last check: {when}, {verdict}")
|
|
43
|
+
next_action = _hold_next_action(hold, identifier)
|
|
44
|
+
if next_action:
|
|
45
|
+
lines.append(f"Next: {next_action}")
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
#: Whole sentences joined with "; " (a method review's findings); a "; " after other text stays inside one finding.
|
|
50
|
+
_JOINED_SENTENCES = re.compile(r"(?<=[.!?»”])\s*;\s+")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _hold_findings(code, detail):
|
|
54
|
+
"""One line per finding the API listed (on "- " lines, or as whole sentences joined by "; ")."""
|
|
55
|
+
if "\n- " in "\n" + detail:
|
|
56
|
+
texts = [finding["text"] for finding in recheck_findings(code, detail, {}, split_message=True)]
|
|
57
|
+
else:
|
|
58
|
+
texts = [sentence for part in _JOINED_SENTENCES.split(detail) for sentence in finding_sentences(part)]
|
|
59
|
+
return [" ".join(text.removeprefix("- ").split()) for text in texts if text.strip()]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _hold_next_action(hold, identifier):
|
|
63
|
+
"""Only offer commands whose recovery operation exists in this CLI."""
|
|
64
|
+
next_step = display_name(hold.get("next_step"))
|
|
65
|
+
action = hold.get("action")
|
|
66
|
+
command = ""
|
|
67
|
+
if identifier:
|
|
68
|
+
identifier = quote(identifier)
|
|
69
|
+
if action in {"regenerate", "requeue_composition"}:
|
|
70
|
+
command = f"recala article regenerate {identifier}"
|
|
71
|
+
elif action == "approve":
|
|
72
|
+
command = f"recala article approve {identifier}"
|
|
73
|
+
elif action == "edit":
|
|
74
|
+
command = (f"recala article edit {identifier} --body-file <file> "
|
|
75
|
+
f"&& recala article recheck {identifier}")
|
|
76
|
+
if action in {"swap_product", "recheck_brand"}:
|
|
77
|
+
command = "(in the portal)"
|
|
78
|
+
return " ".join(value for value in (next_step, command) if value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _json_hold_message(hold, article_id=None):
|
|
82
|
+
"""Preserve the existing JSON error.message bytes; human views use C1 above."""
|
|
83
|
+
if not hold:
|
|
84
|
+
return ""
|
|
85
|
+
code = hold.get("code") or ", ".join(hold.get("codes") or []) or "article_held"
|
|
86
|
+
detail = hold.get("detail") or describe_fields(hold.get("reasons") or {})
|
|
87
|
+
explanation = hold.get("explanation") if hold.get("recognised", True) else None
|
|
88
|
+
text = f"Held ({code}): " + " — ".join(str(v) for v in (detail, explanation) if v)
|
|
89
|
+
action = hold.get("action") or ""
|
|
90
|
+
if article_id:
|
|
91
|
+
identifier = quote(str(article_id))
|
|
92
|
+
if action in {"regenerate", "requeue_composition"} or "re-run" in text.lower():
|
|
93
|
+
text += f"\nNext: recala article regenerate {identifier}"
|
|
94
|
+
elif action in {"edit", "recheck_brand", "swap_product"}:
|
|
95
|
+
text += f"\nNext: recala article edit {identifier} --body-file <file>; recala article recheck {identifier}"
|
|
96
|
+
elif action == "approve":
|
|
97
|
+
text += f"\nNext: recala article approve {identifier}"
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
#: The research allowance's stop: no re-run can change it until the ledger's day resets.
|
|
102
|
+
RESEARCH_QUOTA_STOP = "method_search_quota_ledger_blocked"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ensure_outcome(result, client=None, *, ctx=None):
|
|
106
|
+
"""Exit 6 for holds/failures; exit 8 when a research allowance stopped the run."""
|
|
107
|
+
quota_evidence = [
|
|
108
|
+
result.get("failure_code"),
|
|
109
|
+
result.get("failure_reason"),
|
|
110
|
+
result.get("errors"),
|
|
111
|
+
result.get("hold"),
|
|
112
|
+
result.get("error_message"),
|
|
113
|
+
result.get("recovery"),
|
|
114
|
+
]
|
|
115
|
+
if RESEARCH_QUOTA_STOP in str(quota_evidence):
|
|
116
|
+
zone = result.get("timezone")
|
|
117
|
+
if not zone and client:
|
|
118
|
+
try:
|
|
119
|
+
zone = (client.call(CADENCE_SHOW).get("cadence") or {}).get("timezone")
|
|
120
|
+
except CliError:
|
|
121
|
+
pass
|
|
122
|
+
# The research ledger's day is UTC, independent of publication cadence.
|
|
123
|
+
reset = result.get("retry_at") or result.get("resets_at")
|
|
124
|
+
if not reset:
|
|
125
|
+
reset = (
|
|
126
|
+
datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
127
|
+
+ timedelta(days=1)
|
|
128
|
+
).isoformat()
|
|
129
|
+
raise CliError(
|
|
130
|
+
ExitCode.RATE_LIMITED,
|
|
131
|
+
RESEARCH_QUOTA_STOP,
|
|
132
|
+
"Daily research limit reached; try again after " + moment_in(reset, zone),
|
|
133
|
+
details=result,
|
|
134
|
+
)
|
|
135
|
+
# "skipped" is a CMS push that never ran (auto-publish off), a normal result;
|
|
136
|
+
# only the run's own state can end as skipped.
|
|
137
|
+
states = {
|
|
138
|
+
result.get("state"),
|
|
139
|
+
result.get("cms_status") if result.get("cms_status") != "skipped" else None,
|
|
140
|
+
result.get("cms_publish_status") if result.get("cms_publish_status") != "skipped" else None,
|
|
141
|
+
}
|
|
142
|
+
failed_states = states & {"held", "failed", "skipped"}
|
|
143
|
+
state = (
|
|
144
|
+
"held" if "held" in states else next(iter(failed_states), result.get("state"))
|
|
145
|
+
)
|
|
146
|
+
if (
|
|
147
|
+
result.get("success") is False
|
|
148
|
+
or state in {"held", "failed", "skipped"}
|
|
149
|
+
or result.get("hold")
|
|
150
|
+
):
|
|
151
|
+
if ctx is not None and ctx.obj.get("as_json"):
|
|
152
|
+
message = _json_hold_message(result.get("hold"), result.get("article_id"))
|
|
153
|
+
else:
|
|
154
|
+
message = hold_text(result.get("hold"), result.get("article_id"), ctx=ctx)
|
|
155
|
+
if not message:
|
|
156
|
+
cause = ": ".join(str(result[key]) for key in ("failure_code", "failure_reason") if result.get(key))
|
|
157
|
+
message = f"Article generation failed ({cause})." if cause else "Article generation failed: " + describe_fields(
|
|
158
|
+
result.get("errors") or result.get("error_message") or state)
|
|
159
|
+
queue_id = result.get("queue_id") or result.get("run_id")
|
|
160
|
+
if queue_id and client and not result.get("article_id"):
|
|
161
|
+
message += f"\nStart again: {restart_command(client, str(queue_id))}"
|
|
162
|
+
raise CliError(
|
|
163
|
+
ExitCode.CONFLICT,
|
|
164
|
+
"article_held"
|
|
165
|
+
if result.get("hold") or state == "held"
|
|
166
|
+
else "generation_failed",
|
|
167
|
+
message,
|
|
168
|
+
details=result,
|
|
169
|
+
)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resume_command(client, identifier, *, article=False):
|
|
174
|
+
return f"recala article {'status' if article else 'run'} {quote(identifier)} --org {quote(client.org_id)}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def restart_command(client, identifier):
|
|
178
|
+
return f"recala article create --topic {quote(identifier)} --org {quote(client.org_id)}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def wait_for_article(ctx, client, identifier, *, regeneration=False, regenerate_id=None):
|
|
182
|
+
next_command = resume_command(client, identifier, article=regeneration)
|
|
183
|
+
|
|
184
|
+
def read():
|
|
185
|
+
if regeneration:
|
|
186
|
+
detail = client.call(ARTICLE_SHOW, path={"article_id": identifier})
|
|
187
|
+
article = detail.get("article") or {}
|
|
188
|
+
return {
|
|
189
|
+
**article,
|
|
190
|
+
"article_id": identifier,
|
|
191
|
+
"hold": detail.get("hold"),
|
|
192
|
+
"state": _regeneration_state(article.get("cms_publish_status")),
|
|
193
|
+
"title": article.get("article_title") or article.get("topic"),
|
|
194
|
+
**({"recovery": detail["recovery"]} if detail.get("recovery") else {}),
|
|
195
|
+
}
|
|
196
|
+
result = client.call(GENERATION_STATUS, path={"queue_id": identifier})
|
|
197
|
+
result.setdefault("queue_id", identifier)
|
|
198
|
+
result["state"] = result.get("state") or result.get("status")
|
|
199
|
+
if result["state"] in {"completed", "published", "failed"} and result.get(
|
|
200
|
+
"article_id"
|
|
201
|
+
):
|
|
202
|
+
detail = client.call(
|
|
203
|
+
ARTICLE_SHOW, path={"article_id": result["article_id"]}
|
|
204
|
+
)
|
|
205
|
+
article = detail.get("article") or {}
|
|
206
|
+
result.update(
|
|
207
|
+
hold=detail.get("hold"),
|
|
208
|
+
title=article.get("article_title") or article.get("topic"),
|
|
209
|
+
cms_status=article.get("cms_publish_status"),
|
|
210
|
+
grade=article.get("evaluation_grade") or None, # the API stores "" for an article it never graded
|
|
211
|
+
)
|
|
212
|
+
if article.get("cms_publish_status") == "held":
|
|
213
|
+
result["state"] = "held"
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
def finished(result):
|
|
217
|
+
state = result.get("state")
|
|
218
|
+
if not state:
|
|
219
|
+
raise CliError(
|
|
220
|
+
ExitCode.UNAVAILABLE,
|
|
221
|
+
"unreadable_run",
|
|
222
|
+
"The API did not report the run's state.",
|
|
223
|
+
)
|
|
224
|
+
return state not in {
|
|
225
|
+
"regenerating",
|
|
226
|
+
"processing",
|
|
227
|
+
"queued",
|
|
228
|
+
"running",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
result = poll_until(
|
|
233
|
+
ctx,
|
|
234
|
+
read,
|
|
235
|
+
finished,
|
|
236
|
+
label="Waiting for article",
|
|
237
|
+
timeout_seconds=7200,
|
|
238
|
+
describe=lambda r: (
|
|
239
|
+
f"{r.get('title') or r.get('topic') or 'Article'}: {r.get('current_node') or r.get('state')}"
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
raise CliError(
|
|
244
|
+
ExitCode.UNAVAILABLE,
|
|
245
|
+
"run_continues",
|
|
246
|
+
f"The run continues. Check it with: {next_command}",
|
|
247
|
+
details={"run_id": identifier, "next": {"command": next_command}},
|
|
248
|
+
) from None
|
|
249
|
+
except CliError as error:
|
|
250
|
+
error.message += f" Check the existing run with: {next_command}."
|
|
251
|
+
raise
|
|
252
|
+
if not regeneration and result.get("state") in {"pending", "interrupted", "cancelled", "canceled"}:
|
|
253
|
+
command = restart_command(client, identifier)
|
|
254
|
+
raise CliError(
|
|
255
|
+
ExitCode.UNAVAILABLE, "run_interrupted",
|
|
256
|
+
f"The run is not running (reset or interrupted). Start again: {command}",
|
|
257
|
+
details={**result, "run_id": identifier, "next": {"command": command}},
|
|
258
|
+
)
|
|
259
|
+
if not regeneration and not _ended_readably(result):
|
|
260
|
+
raise CliError(
|
|
261
|
+
ExitCode.UNAVAILABLE, "unexpected_run_state",
|
|
262
|
+
f"The run ended as '{result.get('state')}' without a readable article. "
|
|
263
|
+
f"Check it with: {next_command}",
|
|
264
|
+
details={**result, "run_id": identifier, "next": {"command": next_command}},
|
|
265
|
+
)
|
|
266
|
+
if regeneration:
|
|
267
|
+
ensure_rerun_ran(result, regenerate_id, ctx=ctx)
|
|
268
|
+
return ensure_outcome(result, client, ctx=ctx)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def failed_rerun(recovery):
|
|
272
|
+
"""The article's receipt of a re-run that stopped before replacing its draft, or None."""
|
|
273
|
+
return recovery if isinstance(recovery, dict) and recovery.get("status") == "failed" else None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def ensure_rerun_ran(result, regenerate_id, *, ctx=None):
|
|
277
|
+
"""Exit 6 when this re-run stopped before replacing the draft: the draft's old hold is not its outcome.
|
|
278
|
+
|
|
279
|
+
A receipt naming another run is an earlier one; without a run id (an older API) it is this run's,
|
|
280
|
+
because a re-run that writes a new draft replaces the receipt. A research-allowance stop is left
|
|
281
|
+
to ``ensure_outcome``, which says when the allowance resets."""
|
|
282
|
+
recovery = failed_rerun(result.get("recovery"))
|
|
283
|
+
if (recovery is None or recovery.get("run_id") not in (None, regenerate_id)
|
|
284
|
+
or RESEARCH_QUOTA_STOP in (recovery.get("refusal_codes") or [])):
|
|
285
|
+
return result
|
|
286
|
+
refused = recovery.get("code") == "rerun_refused"
|
|
287
|
+
as_json = ctx is not None and ctx.obj.get("as_json")
|
|
288
|
+
raise CliError(ExitCode.CONFLICT, "rerun_refused" if refused else "rerun_failed",
|
|
289
|
+
rerun_stop_text(recovery, result.get("hold"), str(result.get("article_id") or ""),
|
|
290
|
+
full_ids=as_json),
|
|
291
|
+
details=result)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def rerun_stop_text(recovery, hold, article_id, *, full_ids=False):
|
|
295
|
+
"""What stopped, why, what still holds the draft, and one next step; agents get the ids in full."""
|
|
296
|
+
refused = recovery.get("code") == "rerun_refused"
|
|
297
|
+
identifier = quote(article_id if full_ids else article_id[:8])
|
|
298
|
+
command = f"recala article regenerate {identifier}"
|
|
299
|
+
message = display_name(recovery.get("message")) or "The re-run stopped. Your previous draft is unchanged."
|
|
300
|
+
if full_ids:
|
|
301
|
+
return f"{message}\nNext: {command}"
|
|
302
|
+
lines = [message]
|
|
303
|
+
if display_name(recovery.get("detail")):
|
|
304
|
+
lines.append(f"Why: {display_name(recovery['detail'])}")
|
|
305
|
+
if hold:
|
|
306
|
+
lines.append(f"Still held: {display_name(hold.get('title')) or 'the article needs attention'}")
|
|
307
|
+
lines.append(f"Next: {_refused_rerun_step(recovery.get('refusal_codes') or [], identifier)}, then run again: {command}"
|
|
308
|
+
if refused else f"Next: try again later: {command}")
|
|
309
|
+
return "\n".join(lines)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _refused_rerun_step(codes, identifier):
|
|
313
|
+
"""What changes the method's answer: the run in progress ending, an author to sign it, or material it can cite."""
|
|
314
|
+
if "method_run_in_flight" in codes:
|
|
315
|
+
return "a run of this article is already in progress; wait for it to finish"
|
|
316
|
+
if "commission_author_missing" in codes:
|
|
317
|
+
return (f"sign it with an active author (recala authors list, then recala article person {identifier} "
|
|
318
|
+
"--person <person>)")
|
|
319
|
+
return "add material Recala can cite (recala knowledge add-url <url> or recala knowledge add <file>)"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _ended_readably(result):
|
|
323
|
+
"""Success needs an article; failed, held and skipped runs carry their own cause."""
|
|
324
|
+
state = result.get("state")
|
|
325
|
+
if state in {"failed", "held", "skipped"}:
|
|
326
|
+
return True
|
|
327
|
+
return state in {"completed", "published"} and bool(result.get("article_id"))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _regeneration_state(cms_publish_status):
|
|
331
|
+
"""The article's CMS status is the regeneration's state; a skipped push is not an outcome."""
|
|
332
|
+
return "draft" if cms_publish_status == "skipped" else cms_publish_status
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def describe_run(result, *, ctx=None):
|
|
336
|
+
prefix = (
|
|
337
|
+
f"Charge mode: {result['charge_mode']}\n" if result.get("charge_mode") else ""
|
|
338
|
+
)
|
|
339
|
+
title = result.get("title") or result.get("topic") or "The article"
|
|
340
|
+
if result.get("article_id"):
|
|
341
|
+
title += f" [{str(result['article_id'])[:8]}]"
|
|
342
|
+
if result.get("hold"):
|
|
343
|
+
return prefix + title + "\n" + hold_text(result["hold"], result.get("article_id"), ctx=ctx)
|
|
344
|
+
if result.get("state") in {"processing", "queued", "running"}:
|
|
345
|
+
return (
|
|
346
|
+
prefix
|
|
347
|
+
+ f"Started: {title} (run {result.get('run_id')}).\nCheck: {result.get('check_command')}"
|
|
348
|
+
)
|
|
349
|
+
if result.get("state") == "published" or result.get("cms_status") == "published":
|
|
350
|
+
outcome = "published."
|
|
351
|
+
elif "skipped" in (result.get("cms_status"), result.get("cms_publish_status")):
|
|
352
|
+
outcome = "Ready for review; not pushed to the CMS."
|
|
353
|
+
else:
|
|
354
|
+
outcome = "ready for review."
|
|
355
|
+
if outcome == "published.":
|
|
356
|
+
return prefix + f"{title}: {outcome}"
|
|
357
|
+
return prefix + f"{title}: {outcome}" + review_next_step(result)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def review_next_step(result):
|
|
361
|
+
"""The grade, then the two commands that take a fresh draft to its signer."""
|
|
362
|
+
letter = result.get("grade") or result.get("evaluation_grade") # a re-run reads the article row itself
|
|
363
|
+
grade = f" Grade {letter}." if letter else ""
|
|
364
|
+
if not result.get("article_id"):
|
|
365
|
+
return grade
|
|
366
|
+
identifier = quote(str(result["article_id"])[:8])
|
|
367
|
+
if letter and str(letter)[0].upper() not in "ABC": # below C the lead reads and fixes it; nobody signs it yet
|
|
368
|
+
return (f"{grade}\nNext: below C it is not ready for anyone to sign. Read it with recala article show "
|
|
369
|
+
f"{identifier} --markdown, fix it with recala article edit {identifier} --body-file <file>, which "
|
|
370
|
+
f"keeps it a draft, then run recala article recheck {identifier}")
|
|
371
|
+
return (f"{grade}\nNext: read it with recala article show {identifier} --markdown, "
|
|
372
|
+
f"then hand it over with recala article submit {identifier}")
|
recala_cli/auth.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Credentials: which token a request carries, keeping a session fresh, and signing in.
|
|
2
|
+
|
|
3
|
+
Two kinds of credential reach the API as a Bearer token:
|
|
4
|
+
|
|
5
|
+
* an **API key** (``rcl_live_…`` / ``rcl_test_…``) for agents and CI: set RECALA_API_KEY,
|
|
6
|
+
or paste it with ``recala login --with-token``. Never refreshed; revoked in Settings.
|
|
7
|
+
* a **session** from ``recala login`` (a device code approved in the browser), which
|
|
8
|
+
carries a refresh token and is renewed a minute before it expires.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import platform
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from contextlib import AbstractContextManager
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from recala_cli import __version__, config
|
|
23
|
+
from recala_cli.client import RecalaClient
|
|
24
|
+
from recala_cli.endpoints import DEVICE_START, DEVICE_TOKEN, TOKEN_REFRESH
|
|
25
|
+
from recala_cli.errors import CliError, ExitCode, session_expired
|
|
26
|
+
|
|
27
|
+
API_KEY_PREFIX = "rcl_"
|
|
28
|
+
_REFRESH_MARGIN_SECONDS = 60
|
|
29
|
+
_SLOW_DOWN_STEP_SECONDS = 5
|
|
30
|
+
_REFUSED_SIGN_IN = {
|
|
31
|
+
"access_denied": "The sign-in was declined in the browser.",
|
|
32
|
+
"expired_token": "The sign-in code expired. Run `recala login` again.",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bearer_token(client: RecalaClient) -> str:
|
|
37
|
+
"""The token for this command: the environment's, else the stored one (refreshed if due)."""
|
|
38
|
+
from_environment = config.environment_token()
|
|
39
|
+
if from_environment:
|
|
40
|
+
return from_environment
|
|
41
|
+
credentials = config.load_credentials()
|
|
42
|
+
if not credentials.get("access_token"):
|
|
43
|
+
raise CliError(
|
|
44
|
+
ExitCode.NOT_AUTHENTICATED, "not_authenticated", "Not signed in. Run `recala login`, or set RECALA_API_KEY."
|
|
45
|
+
)
|
|
46
|
+
if (credentials.get("kind") == "session" or credentials.get("refresh_token")) and _due_for_refresh(credentials):
|
|
47
|
+
credentials = _refresh(client, credentials)
|
|
48
|
+
if credentials.get("kind") == "session" or credentials.get("refresh_token"):
|
|
49
|
+
client.refresh_session = lambda: _recover_session(client, credentials.get("session_id"))
|
|
50
|
+
return str(credentials["access_token"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _recover_session(client: RecalaClient, session_id: str | None) -> str:
|
|
54
|
+
credentials = config.load_credentials()
|
|
55
|
+
if (credentials.get("kind") != "session" and not credentials.get("refresh_token")) or credentials.get("session_id") != session_id:
|
|
56
|
+
raise session_expired()
|
|
57
|
+
token = credentials.get("access_token")
|
|
58
|
+
if token and token != client.token:
|
|
59
|
+
return str(token) # another process already rotated the stored session
|
|
60
|
+
return str(_refresh(client, credentials)["access_token"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def credential_kind() -> str:
|
|
64
|
+
"""What this command authenticates with: "api_key", "session" or "access_token"."""
|
|
65
|
+
from_environment = config.environment_token()
|
|
66
|
+
if from_environment:
|
|
67
|
+
return "api_key" if from_environment.startswith(API_KEY_PREFIX) else "access_token"
|
|
68
|
+
credentials = config.load_credentials()
|
|
69
|
+
if credentials.get("kind"):
|
|
70
|
+
return str(credentials["kind"])
|
|
71
|
+
token = str(credentials.get("access_token") or "")
|
|
72
|
+
return ("api_key" if token.startswith(API_KEY_PREFIX) else "access_token") if token else "none"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def session_id() -> str | None:
|
|
76
|
+
"""The stored sign-in's local id (an [a]ll grant is bound to it); None for keys and environment tokens."""
|
|
77
|
+
if config.environment_token():
|
|
78
|
+
return None
|
|
79
|
+
credentials = config.load_credentials()
|
|
80
|
+
return None if credentials.get("kind") == "api_key" else credentials.get("session_id")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def store_pasted_token(token: str) -> dict[str, Any]:
|
|
84
|
+
token = token.strip()
|
|
85
|
+
if not token:
|
|
86
|
+
raise CliError(ExitCode.USAGE, "empty_token", "No token on standard input.")
|
|
87
|
+
kind = "api_key" if token.startswith(API_KEY_PREFIX) else "access_token"
|
|
88
|
+
stored: dict[str, Any] = {"kind": kind, "access_token": token}
|
|
89
|
+
if kind != "api_key":
|
|
90
|
+
stored["session_id"] = _new_session_id() # a person's token: an [a]ll answer may be recorded for it
|
|
91
|
+
config.save_credentials(stored)
|
|
92
|
+
config.forget_grant()
|
|
93
|
+
return {"signed_in": True, "kind": kind}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_OS_NAMES = {"Darwin": "macOS", "Linux": "Linux", "Windows": "Windows"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def client_label() -> str:
|
|
100
|
+
"""What the browser shows next to the code ("Recala CLI 0.3.0 on macOS"): the machine's kind, never its name."""
|
|
101
|
+
system = platform.system()
|
|
102
|
+
return f"Recala CLI {__version__} on {_OS_NAMES.get(system, system or 'a computer')}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _new_session_id() -> str:
|
|
106
|
+
"""Names one sign-in on this machine so an [a]ll grant ends with it. Local only, never sent."""
|
|
107
|
+
return uuid.uuid4().hex
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def device_login(
|
|
111
|
+
client: RecalaClient,
|
|
112
|
+
*,
|
|
113
|
+
sleep: Callable[[float], None],
|
|
114
|
+
open_browser: Callable[[str], Any] | None,
|
|
115
|
+
present_code: Callable[[str, str], None],
|
|
116
|
+
waiting: Callable[[], AbstractContextManager[Any]] = contextlib.nullcontext,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""RFC 8628 device authorization: show a code, let the person approve it, poll for the session."""
|
|
119
|
+
grant = client.call(DEVICE_START, json={"client": client_label()}, authenticated=False)
|
|
120
|
+
present_code(grant["verification_uri"], grant["user_code"])
|
|
121
|
+
if open_browser is not None:
|
|
122
|
+
open_browser(grant.get("verification_uri_complete") or grant["verification_uri"])
|
|
123
|
+
with waiting():
|
|
124
|
+
session = _poll_for_session(client, grant, sleep)
|
|
125
|
+
config.save_credentials({**_session_from(session), "session_id": _new_session_id()})
|
|
126
|
+
config.forget_grant()
|
|
127
|
+
config.set_web_url(_origin(grant["verification_uri"]))
|
|
128
|
+
return {"signed_in": True, "email": (session.get("user") or {}).get("email")}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _origin(url: str) -> str:
|
|
132
|
+
parts = urlsplit(url)
|
|
133
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _poll_for_session(client: RecalaClient, grant: dict[str, Any], sleep: Callable[[float], None]) -> dict[str, Any]:
|
|
137
|
+
interval, waited, lifetime = int(grant.get("interval", 5)), 0, int(grant.get("expires_in", 600))
|
|
138
|
+
while waited < lifetime:
|
|
139
|
+
sleep(interval)
|
|
140
|
+
waited += interval
|
|
141
|
+
try:
|
|
142
|
+
session = client.call(DEVICE_TOKEN, json={"device_code": grant["device_code"]}, authenticated=False)
|
|
143
|
+
except CliError as refusal:
|
|
144
|
+
if refusal.code == "authorization_pending":
|
|
145
|
+
continue
|
|
146
|
+
if refusal.code == "slow_down":
|
|
147
|
+
interval += _SLOW_DOWN_STEP_SECONDS
|
|
148
|
+
continue
|
|
149
|
+
if refusal.code in _REFUSED_SIGN_IN:
|
|
150
|
+
message = _REFUSED_SIGN_IN[refusal.code]
|
|
151
|
+
raise CliError(ExitCode.NOT_AUTHENTICATED, refusal.code, message, refusal.status) from refusal
|
|
152
|
+
raise
|
|
153
|
+
answer: dict[str, Any] = session
|
|
154
|
+
return answer
|
|
155
|
+
raise CliError(ExitCode.NOT_AUTHENTICATED, "expired_token", _REFUSED_SIGN_IN["expired_token"])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _due_for_refresh(credentials: dict[str, Any]) -> bool:
|
|
159
|
+
expires_at = credentials.get("expires_at")
|
|
160
|
+
return expires_at is not None and time.time() >= float(expires_at) - _REFRESH_MARGIN_SECONDS
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _refresh(client: RecalaClient, credentials: dict[str, Any]) -> dict[str, Any]:
|
|
164
|
+
expired = session_expired()
|
|
165
|
+
if not credentials.get("refresh_token"):
|
|
166
|
+
raise expired
|
|
167
|
+
try:
|
|
168
|
+
session = client.call(TOKEN_REFRESH, json={"refresh_token": credentials["refresh_token"]}, authenticated=False)
|
|
169
|
+
except ValueError as invalid_response:
|
|
170
|
+
raise expired from invalid_response
|
|
171
|
+
except CliError as refusal:
|
|
172
|
+
if refusal.exit_code in (ExitCode.NOT_AUTHENTICATED, ExitCode.INVALID):
|
|
173
|
+
expired.status = refusal.status
|
|
174
|
+
raise expired from refusal
|
|
175
|
+
raise
|
|
176
|
+
if not isinstance(session, dict) or not isinstance(session.get("access_token"), str) or not session["access_token"]:
|
|
177
|
+
raise expired
|
|
178
|
+
try:
|
|
179
|
+
renewed = {**credentials, **_session_from(session)}
|
|
180
|
+
except (TypeError, ValueError) as invalid_response:
|
|
181
|
+
raise expired from invalid_response
|
|
182
|
+
config.save_credentials(renewed)
|
|
183
|
+
return renewed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _session_from(session: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
stored: dict[str, Any] = {
|
|
188
|
+
"kind": "session",
|
|
189
|
+
"access_token": session["access_token"],
|
|
190
|
+
"refresh_token": session.get("refresh_token"),
|
|
191
|
+
"expires_at": time.time() + int(session.get("expires_in", 3600)),
|
|
192
|
+
}
|
|
193
|
+
email = (session.get("user") or {}).get("email")
|
|
194
|
+
if email:
|
|
195
|
+
stored["email"] = email
|
|
196
|
+
return stored
|
recala_cli/client.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The one HTTP client: base URL, bearer token, organization header, error mapping.
|
|
2
|
+
|
|
3
|
+
Every command goes through ``RecalaClient.call``. It sends X-Recala-Org-ID, the
|
|
4
|
+
header the API's get_current_user selects a company by, and turns every refusal
|
|
5
|
+
or network failure into a CliError with a stable exit code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from recala_cli import __version__
|
|
16
|
+
from recala_cli.endpoints import ME, Endpoint
|
|
17
|
+
from recala_cli.errors import CliError, DryRunStop, error_from_response, network_failure, session_expired
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _auth_layer_refusal(response: httpx.Response) -> bool:
|
|
21
|
+
if response.status_code != 401:
|
|
22
|
+
return False
|
|
23
|
+
try:
|
|
24
|
+
body = response.json()
|
|
25
|
+
except ValueError:
|
|
26
|
+
return False
|
|
27
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
28
|
+
return isinstance(detail, str) and detail in ("Invalid token", "Invalid user", "Missing Bearer token")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RecalaClient:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
api_url: str,
|
|
35
|
+
*,
|
|
36
|
+
org_id: str | None = None,
|
|
37
|
+
transport: httpx.BaseTransport | None = None,
|
|
38
|
+
timeout: float = 60.0,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.org_id = org_id
|
|
41
|
+
self.token: str | None = None
|
|
42
|
+
self._me: Any = None # GET /me answered once per command; any write forgets it
|
|
43
|
+
self.refresh_session: Callable[[], str] | None = None
|
|
44
|
+
self.dry_run = False # --dry-run: reads go out, no write ever does
|
|
45
|
+
self._http = httpx.Client(
|
|
46
|
+
base_url=api_url,
|
|
47
|
+
transport=transport,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
headers={"User-Agent": f"recala-cli/{__version__}"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def known_me(self) -> Any:
|
|
54
|
+
"""GET /me as this command already read it, else None."""
|
|
55
|
+
return self._me
|
|
56
|
+
|
|
57
|
+
def call(
|
|
58
|
+
self,
|
|
59
|
+
endpoint: Endpoint,
|
|
60
|
+
*,
|
|
61
|
+
path: dict[str, str] | None = None,
|
|
62
|
+
params: dict[str, Any] | None = None,
|
|
63
|
+
json: Any = None,
|
|
64
|
+
content: bytes | None = None,
|
|
65
|
+
extra_headers: dict[str, str] | None = None,
|
|
66
|
+
authenticated: bool = True,
|
|
67
|
+
timeout: float | None = None,
|
|
68
|
+
) -> Any:
|
|
69
|
+
if endpoint is ME and self._me is not None:
|
|
70
|
+
return self._me
|
|
71
|
+
if self.dry_run and endpoint.writes:
|
|
72
|
+
raise DryRunStop(f"send {endpoint.method} {endpoint.url_path(**(path or {}))}")
|
|
73
|
+
headers = dict(extra_headers or {})
|
|
74
|
+
if authenticated:
|
|
75
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
76
|
+
if self.org_id and "X-Recala-Org-ID" not in headers:
|
|
77
|
+
headers["X-Recala-Org-ID"] = self.org_id
|
|
78
|
+
for attempt in range(2):
|
|
79
|
+
try:
|
|
80
|
+
response = self._http.request(
|
|
81
|
+
endpoint.method, endpoint.url_path(**(path or {})), params=params, json=json, content=content,
|
|
82
|
+
headers=headers, timeout=httpx.USE_CLIENT_DEFAULT if timeout is None else timeout,
|
|
83
|
+
)
|
|
84
|
+
except httpx.HTTPError as exc:
|
|
85
|
+
raise network_failure(exc, writes=endpoint.writes) from exc
|
|
86
|
+
if _auth_layer_refusal(response) and authenticated:
|
|
87
|
+
expired = session_expired(response.status_code, code=error_from_response(response).code)
|
|
88
|
+
if attempt or self.refresh_session is None:
|
|
89
|
+
raise expired
|
|
90
|
+
try:
|
|
91
|
+
self.token = self.refresh_session()
|
|
92
|
+
except CliError as refusal:
|
|
93
|
+
raise expired from refusal
|
|
94
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
95
|
+
continue
|
|
96
|
+
if response.is_error:
|
|
97
|
+
raise error_from_response(response, writes=endpoint.writes)
|
|
98
|
+
answer = response.json() if response.content else None
|
|
99
|
+
if endpoint is ME:
|
|
100
|
+
self._me = answer
|
|
101
|
+
elif endpoint.writes:
|
|
102
|
+
self._me = None
|
|
103
|
+
return answer
|