deepcell-cli 0.6.1__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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""``deepcell impact`` — what still needs review after a change.
|
|
2
|
+
|
|
3
|
+
Mirrors ``backend/jingwei_api/routers/impact.py`` 1:1. A thin pass-through, so
|
|
4
|
+
the CLI, the viewer and an agent get the same answer about what a change
|
|
5
|
+
reached and what counted as material — the judgement lives once, in
|
|
6
|
+
``src/core/impact``, and nothing here re-decides any of it.
|
|
7
|
+
|
|
8
|
+
This replaces the six-command sequence `guide revise/premise-change` §2 taught
|
|
9
|
+
by hand (`assumption impact`, `reasoning impact`, `relationships`, `cell-meta`
|
|
10
|
+
and one `doc backlinks` per affected id). Those still work and still answer
|
|
11
|
+
narrower questions; this answers the whole one in a single call, and is the
|
|
12
|
+
only one that carries the impact through to the Document sections and Deck
|
|
13
|
+
slides rather than stopping at the reasoning node.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import shlex
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
25
|
+
from deepcell_cli.output import echo_info, echo_success, output, print_plain
|
|
26
|
+
|
|
27
|
+
from ._swapped_args import FileFirstCommand, FileFirstGroup
|
|
28
|
+
|
|
29
|
+
#: How a precision reads to a person. The distinction is the reason an affected
|
|
30
|
+
#: set stops being either too broad or incomplete, so it is spelled out rather
|
|
31
|
+
#: than printed as a term of art.
|
|
32
|
+
_PRECISION = {"exact": "uses this value", "row": "uses this row"}
|
|
33
|
+
|
|
34
|
+
#: Kinds that reached a place without citing anything, so no precision phrase
|
|
35
|
+
#: describes them. Named here rather than imported: `backend/` is not on the
|
|
36
|
+
#: CLI's path, and this is a rendering choice about one line of output, not a
|
|
37
|
+
#: second opinion about what the kind means.
|
|
38
|
+
_NOT_A_REFERENCE = {"stale_literal"}
|
|
39
|
+
|
|
40
|
+
#: `HEAD` or `HEAD~N`, the spelling `describe --lint --since` already takes.
|
|
41
|
+
#: Case-insensitive: `head~1` reached the server as-is and came back a 422.
|
|
42
|
+
_HEAD_REVISION = re.compile(r"^HEAD(?:~(\d+))?$", re.IGNORECASE)
|
|
43
|
+
|
|
44
|
+
#: What the server accepts verbatim: a 7-64 character hex SHA.
|
|
45
|
+
_SHA_REVISION = re.compile(r"^[0-9a-fA-F]{7,64}$")
|
|
46
|
+
|
|
47
|
+
_SINCE_HELP = (
|
|
48
|
+
"The revision to compare from: a SHA from `deepcell log`, or HEAD~N for "
|
|
49
|
+
"the N-th commit before the workspace's newest (resolved through the log "
|
|
50
|
+
"here; the server takes a 7-64 character SHA only). Note the SHA before "
|
|
51
|
+
"you edit — a premise change is usually several commits, and HEAD~1 "
|
|
52
|
+
"reaches only the last of them."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_revision(ctx: Ctx, slug: str, revision: str, option: str) -> str:
|
|
57
|
+
"""Turn `HEAD~N` into the SHA the server insists on.
|
|
58
|
+
|
|
59
|
+
`option` is the flag the value came from (`--since` or `--revision`), so
|
|
60
|
+
the usage error names the one to fix.
|
|
61
|
+
|
|
62
|
+
The server rejects anything that is not a hex SHA, so the CLI resolves the
|
|
63
|
+
shorthand the same way a reader would by hand: the workspace log, newest
|
|
64
|
+
first, whichever file each commit touched — which is what git's `HEAD~N`
|
|
65
|
+
means on the linear `main` every workspace has. The file filter is left
|
|
66
|
+
off on purpose: `describe --lint --since HEAD~1` is git-native and counts
|
|
67
|
+
every commit, and two commands spelling `HEAD~1` as different commits
|
|
68
|
+
would be worse than one spelling it only as a SHA.
|
|
69
|
+
"""
|
|
70
|
+
match = _HEAD_REVISION.match(revision)
|
|
71
|
+
if match is None:
|
|
72
|
+
if _SHA_REVISION.match(revision):
|
|
73
|
+
return revision
|
|
74
|
+
# Anything else would round-trip to the server as a 422 in
|
|
75
|
+
# pydantic's words; say what the two accepted spellings are here.
|
|
76
|
+
raise click.BadParameter(
|
|
77
|
+
f"{revision!r} is neither a SHA (7-64 hex characters, from "
|
|
78
|
+
"`deepcell log`) nor HEAD~N.",
|
|
79
|
+
param_hint=option,
|
|
80
|
+
)
|
|
81
|
+
depth = int(match.group(1) or 0)
|
|
82
|
+
commits = ctx.client.get(
|
|
83
|
+
f"/workspaces/{slug}/versions", params={"max_count": depth + 1},
|
|
84
|
+
)
|
|
85
|
+
if not isinstance(commits, list) or len(commits) <= depth:
|
|
86
|
+
raise click.BadParameter(
|
|
87
|
+
f"{revision} is past the start of this workspace's history "
|
|
88
|
+
f"({len(commits) if isinstance(commits, list) else 0} commit(s)). "
|
|
89
|
+
"Pass a SHA from `deepcell log`.",
|
|
90
|
+
param_hint=option,
|
|
91
|
+
)
|
|
92
|
+
return commits[depth]["sha"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@click.group(cls=FileFirstGroup)
|
|
96
|
+
def impact() -> None:
|
|
97
|
+
"""What a change reached, and what still needs review."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@impact.command("show", cls=FileFirstCommand)
|
|
101
|
+
@click.argument("filename")
|
|
102
|
+
@click.option("--since", "base_revision", required=True, help=_SINCE_HELP)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--revision", default=None,
|
|
105
|
+
help="The revision to compare to (a SHA or HEAD~N). Defaults to the "
|
|
106
|
+
"working tree.",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--threshold", type=float, default=None,
|
|
110
|
+
help=(
|
|
111
|
+
"Relative move at or above which a value change is material. "
|
|
112
|
+
"Defaults to the built-in 1%."
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--include-reviewed", is_flag=True, default=False,
|
|
117
|
+
help="Also show items somebody has already marked reviewed.",
|
|
118
|
+
)
|
|
119
|
+
@pass_ctx
|
|
120
|
+
def show(
|
|
121
|
+
ctx: Ctx,
|
|
122
|
+
filename: str,
|
|
123
|
+
base_revision: str,
|
|
124
|
+
revision: str | None,
|
|
125
|
+
threshold: float | None,
|
|
126
|
+
include_reviewed: bool,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""List every place that may need review after a change.
|
|
129
|
+
|
|
130
|
+
Run it AFTER the edit lands: it compares two revisions, so before the
|
|
131
|
+
edit there is nothing to compare. Every item prints its `locator` and a
|
|
132
|
+
ready-to-paste `impact review` line — the review command needs both the
|
|
133
|
+
surface and the locator, and this is the only place they are printed.
|
|
134
|
+
|
|
135
|
+
Same-file only. Inverting cross-file links would mean scanning every
|
|
136
|
+
.deepcell in the workspace, which has no index and would undercount while
|
|
137
|
+
looking authoritative — so the scope is printed rather than assumed.
|
|
138
|
+
"""
|
|
139
|
+
slug = ctx.require_workspace()
|
|
140
|
+
body = {
|
|
141
|
+
"workspace_slug": slug,
|
|
142
|
+
"source_filename": filename,
|
|
143
|
+
"base_revision": _resolve_revision(ctx, slug, base_revision, "--since"),
|
|
144
|
+
"include_reviewed": include_reviewed,
|
|
145
|
+
}
|
|
146
|
+
if revision is not None:
|
|
147
|
+
body["revision"] = _resolve_revision(ctx, slug, revision, "--revision")
|
|
148
|
+
if threshold is not None:
|
|
149
|
+
body["threshold"] = threshold
|
|
150
|
+
|
|
151
|
+
data = ctx.client.post("/impact", json=body)
|
|
152
|
+
if ctx.fmt != "plain":
|
|
153
|
+
output(data, ctx.fmt)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
items = data.get("items", [])
|
|
157
|
+
# A created file whose queue somebody has WORKED THROUGH is aligned in the
|
|
158
|
+
# ordinary sense, and saying it "holds nothing to review" would deny the
|
|
159
|
+
# review that just happened — on a payload whose own `reviewed` counts it.
|
|
160
|
+
never_reviewed = not data.get("reviewed")
|
|
161
|
+
if not items:
|
|
162
|
+
if data.get("base_absent") and never_reviewed:
|
|
163
|
+
# `--since` named a revision older than the file itself, and the
|
|
164
|
+
# new file cites nothing a reader has to judge. "Everything is
|
|
165
|
+
# aligned" would credit a re-check that never had anything to do.
|
|
166
|
+
echo_success(
|
|
167
|
+
f"{filename} did not exist at that revision — this change "
|
|
168
|
+
f"created it, and it holds nothing to review."
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
echo_success("Everything is aligned.")
|
|
172
|
+
_print_summary(data.get("summary") or {})
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
counts = data.get("counts") or {}
|
|
176
|
+
tally = " · ".join(f"{surface} {n}" for surface, n in sorted(counts.items()))
|
|
177
|
+
echo_info(f"{len(items)} place(s) need review — {tally}")
|
|
178
|
+
if data.get("base_absent") and never_reviewed:
|
|
179
|
+
# Every place is listed because the file is new, not because a change
|
|
180
|
+
# reached it. Without this the count reads as damage. Dropped once
|
|
181
|
+
# anything has been reviewed, when the claim stops being true.
|
|
182
|
+
print_plain(
|
|
183
|
+
f"{filename} did not exist at that revision — this change created "
|
|
184
|
+
f"it, so nothing here has been reviewed yet."
|
|
185
|
+
)
|
|
186
|
+
print_plain(f"scope: {data.get('scope', 'this file only')}")
|
|
187
|
+
print_plain("")
|
|
188
|
+
|
|
189
|
+
for item in items:
|
|
190
|
+
marker = "!" if item["state"] == "unresolved" else "·"
|
|
191
|
+
agent = " [agent can help]" if item.get("agent_eligible") else ""
|
|
192
|
+
print_plain(f"{marker} {item['surface'].upper()} {item['display']}{agent}")
|
|
193
|
+
print_plain(f" {item['cause']['summary']}")
|
|
194
|
+
if item["kind"] in _NOT_A_REFERENCE:
|
|
195
|
+
# Every phrase in `_PRECISION` describes how a *citation* addresses
|
|
196
|
+
# what moved, and these kinds are not citations — the text spells
|
|
197
|
+
# the number out. "Uses this value" beside "the text writes 200.75
|
|
198
|
+
# out as a literal" contradicts itself in two consecutive lines.
|
|
199
|
+
print_plain(f" {item['gate_reason']}")
|
|
200
|
+
else:
|
|
201
|
+
print_plain(
|
|
202
|
+
f" {_PRECISION.get(item['precision'], item['precision'])}"
|
|
203
|
+
f" — {item['gate_reason']}"
|
|
204
|
+
)
|
|
205
|
+
if item.get("path"):
|
|
206
|
+
# "Why is this affected?" — the nodes the change travelled through.
|
|
207
|
+
print_plain(f" via {' -> '.join(item['path'])}")
|
|
208
|
+
print_plain(f" key {item['item_key']}")
|
|
209
|
+
print_plain(f" locator {item['locator']}")
|
|
210
|
+
# The next command, spelled out. `impact review` needs the surface and
|
|
211
|
+
# the locator as well as the key, and nothing else prints the locator
|
|
212
|
+
# — without this line the listing could not be acted on from its own
|
|
213
|
+
# output. A broken reference gets no review line because the server
|
|
214
|
+
# refuses one: it is rebound, not reviewed.
|
|
215
|
+
if item["state"] == "unresolved":
|
|
216
|
+
print_plain(" rebind the reference; a review does not clear it")
|
|
217
|
+
else:
|
|
218
|
+
print_plain(f" review: {_review_line(filename, item)}")
|
|
219
|
+
print_plain("")
|
|
220
|
+
|
|
221
|
+
unresolved = data.get("unresolved", 0)
|
|
222
|
+
if unresolved:
|
|
223
|
+
# Said out loud because it is the one thing that stops this file being
|
|
224
|
+
# aligned however many paragraphs get confirmed.
|
|
225
|
+
echo_info(
|
|
226
|
+
f"{unresolved} unresolved reference(s) — these need a rebind, "
|
|
227
|
+
"not a review."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@impact.command("review", cls=FileFirstCommand)
|
|
232
|
+
@click.argument("filename")
|
|
233
|
+
@click.argument("item_key")
|
|
234
|
+
@click.option(
|
|
235
|
+
"--surface", required=True,
|
|
236
|
+
help="The surface the item is on, as `impact show` prints it.",
|
|
237
|
+
)
|
|
238
|
+
@click.option(
|
|
239
|
+
"--locator", required=True,
|
|
240
|
+
help="The item's address — the `locator` line `impact show` prints.",
|
|
241
|
+
)
|
|
242
|
+
@click.option(
|
|
243
|
+
"--outcome", type=click.Choice(["confirmed", "revised"]), default="confirmed",
|
|
244
|
+
help="confirmed: it still reads correctly. revised: it was rewritten.",
|
|
245
|
+
)
|
|
246
|
+
@click.option(
|
|
247
|
+
"--kind", default=None,
|
|
248
|
+
help="The item's kind. An unresolved one is refused: it needs a rebind.",
|
|
249
|
+
)
|
|
250
|
+
@pass_ctx
|
|
251
|
+
def review(
|
|
252
|
+
ctx: Ctx, filename: str, item_key: str, surface: str, locator: str,
|
|
253
|
+
outcome: str, kind: str | None,
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Mark one item reviewed — the marker disappears.
|
|
256
|
+
|
|
257
|
+
`impact show` prints this command ready to paste under every item, key,
|
|
258
|
+
surface and locator filled in. It comes back on its own if the same place
|
|
259
|
+
is reached by a *new* change: the key folds in what changed, not only
|
|
260
|
+
where.
|
|
261
|
+
|
|
262
|
+
`--kind` is optional and worth passing: a `broken_ref` is refused outright,
|
|
263
|
+
rather than recorded and then ignored on the next read because a review
|
|
264
|
+
decision cannot rebind an address.
|
|
265
|
+
"""
|
|
266
|
+
ctx.client.post("/impact/review", json={
|
|
267
|
+
"workspace_slug": ctx.require_workspace(),
|
|
268
|
+
"source_filename": filename,
|
|
269
|
+
"item_key": item_key,
|
|
270
|
+
"surface": surface,
|
|
271
|
+
"locator": locator,
|
|
272
|
+
"outcome": outcome,
|
|
273
|
+
**({"kind": kind} if kind else {}),
|
|
274
|
+
})
|
|
275
|
+
echo_success(f"Reviewed ({outcome}): {item_key}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@impact.command("apply", cls=FileFirstCommand)
|
|
279
|
+
@click.argument("filename")
|
|
280
|
+
@click.argument("item_key")
|
|
281
|
+
@click.option("--surface", required=True, help="The surface the item is on.")
|
|
282
|
+
@click.option("--locator", required=True, help="The item's address.")
|
|
283
|
+
@click.option("--op", required=True, help="The reasoning op to apply.")
|
|
284
|
+
@click.option("--target-id", required=True, help="The Claim or Assumption it addresses.")
|
|
285
|
+
@click.option(
|
|
286
|
+
"--payload", default=None,
|
|
287
|
+
help="The op's fields, as JSON. Omit for an op that needs none.",
|
|
288
|
+
)
|
|
289
|
+
@click.option(
|
|
290
|
+
"--new-claim", default=None,
|
|
291
|
+
help="JSON for the replacement Claim. Required by supersede_claim.",
|
|
292
|
+
)
|
|
293
|
+
@click.option(
|
|
294
|
+
"--new-assumption", default=None,
|
|
295
|
+
help="JSON for the replacement Assumption. Required by supersede_assumption.",
|
|
296
|
+
)
|
|
297
|
+
@click.option(
|
|
298
|
+
"--expected-revision", default=None,
|
|
299
|
+
help="The revision you read at. A newer document is a 409 rather than an "
|
|
300
|
+
"overwrite.",
|
|
301
|
+
)
|
|
302
|
+
@pass_ctx
|
|
303
|
+
def apply(
|
|
304
|
+
ctx: Ctx, filename: str, item_key: str, surface: str, locator: str,
|
|
305
|
+
op: str, target_id: str, payload: str | None, new_claim: str | None,
|
|
306
|
+
new_assumption: str | None, expected_revision: str | None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Apply one reviewed fix, and record it as `revised`.
|
|
309
|
+
|
|
310
|
+
The write and the ledger row are one decision. Applied and unrecorded, the
|
|
311
|
+
item comes back on the next read as though nothing happened and you fix it
|
|
312
|
+
twice.
|
|
313
|
+
|
|
314
|
+
Only reasoning ops, and only the ones a review may make: no `delete_*`
|
|
315
|
+
(supersession preserves history a delete destroys) and no
|
|
316
|
+
`set_conclusion` / `set_key_question` (reframing the document is the
|
|
317
|
+
author's call). Anything else is refused, whatever proposed it.
|
|
318
|
+
"""
|
|
319
|
+
def _json(flag: str, raw: str | None):
|
|
320
|
+
if raw is None:
|
|
321
|
+
return None
|
|
322
|
+
try:
|
|
323
|
+
return json.loads(raw)
|
|
324
|
+
except json.JSONDecodeError as exc:
|
|
325
|
+
raise click.BadParameter(f"{flag} is not valid JSON: {exc}")
|
|
326
|
+
|
|
327
|
+
data = ctx.client.post("/impact/apply", json={
|
|
328
|
+
"workspace_slug": ctx.require_workspace(),
|
|
329
|
+
"source_filename": filename,
|
|
330
|
+
"item_key": item_key,
|
|
331
|
+
"surface": surface,
|
|
332
|
+
"locator": locator,
|
|
333
|
+
"op": op,
|
|
334
|
+
"target_id": target_id,
|
|
335
|
+
"payload": _json("--payload", payload) or {},
|
|
336
|
+
**({"new_claim": _json("--new-claim", new_claim)} if new_claim else {}),
|
|
337
|
+
**({"new_assumption": _json("--new-assumption", new_assumption)}
|
|
338
|
+
if new_assumption else {}),
|
|
339
|
+
**({"expected_revision": expected_revision} if expected_revision else {}),
|
|
340
|
+
})
|
|
341
|
+
echo_success(f"Applied and recorded as revised: {item_key}")
|
|
342
|
+
if data.get("revision"):
|
|
343
|
+
echo_info(f"Now at {data['revision']}.")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@impact.command("reopen", cls=FileFirstCommand)
|
|
347
|
+
@click.argument("filename")
|
|
348
|
+
@click.argument("item_key")
|
|
349
|
+
@pass_ctx
|
|
350
|
+
def reopen(ctx: Ctx, filename: str, item_key: str) -> None:
|
|
351
|
+
"""Undo one review decision."""
|
|
352
|
+
data = ctx.client.post("/impact/reopen", json={
|
|
353
|
+
"workspace_slug": ctx.require_workspace(),
|
|
354
|
+
"source_filename": filename,
|
|
355
|
+
"item_key": item_key,
|
|
356
|
+
})
|
|
357
|
+
if data.get("removed"):
|
|
358
|
+
echo_success(f"Reopened: {item_key}")
|
|
359
|
+
else:
|
|
360
|
+
echo_info(f"Nothing to reopen: {item_key} was not marked reviewed.")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _review_line(filename: str, item: dict) -> str:
|
|
364
|
+
"""The `impact review` invocation that clears `item`, shell-quoted."""
|
|
365
|
+
return (
|
|
366
|
+
f"deepcell impact review {shlex.quote(filename)} {item['item_key']}"
|
|
367
|
+
f" --surface {item['surface']} --locator {shlex.quote(item['locator'])}"
|
|
368
|
+
f" --kind {item['kind']} --outcome confirmed"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _print_summary(summary: dict) -> None:
|
|
373
|
+
if not summary:
|
|
374
|
+
return
|
|
375
|
+
lines = [
|
|
376
|
+
(summary.get("updated_automatically", 0), "value(s) updated automatically"),
|
|
377
|
+
(summary.get("confirmed", 0), "item(s) confirmed"),
|
|
378
|
+
(summary.get("revised", 0), "item(s) revised"),
|
|
379
|
+
]
|
|
380
|
+
for count, label in lines:
|
|
381
|
+
if count:
|
|
382
|
+
print_plain(f" {count} {label}")
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""``deepcell import`` — import xlsx/csv files into .deepcell documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from deepcell_cli.commands._datatypes import warn_unrecognized_data_type
|
|
11
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
12
|
+
from deepcell_cli.output import echo_success, echo_warning
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_json_arg(value: str) -> list:
|
|
16
|
+
"""Load JSON from a string or file path."""
|
|
17
|
+
path = Path(value)
|
|
18
|
+
try:
|
|
19
|
+
is_file = path.exists()
|
|
20
|
+
except OSError:
|
|
21
|
+
# `Path.exists()` raises ENAMETOOLONG on an inline payload longer
|
|
22
|
+
# than a path component, which read as "Import failed" for any items
|
|
23
|
+
# list past a few entries. A string that cannot be a path is JSON.
|
|
24
|
+
is_file = False
|
|
25
|
+
if is_file:
|
|
26
|
+
return json.loads(path.read_text())
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(value)
|
|
29
|
+
except json.JSONDecodeError as e:
|
|
30
|
+
raise click.ClickException(f"Invalid JSON: {e}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.command("import")
|
|
34
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
35
|
+
@click.option("--items", required=True, help="Items JSON string or path to JSON file.")
|
|
36
|
+
@click.option("--contexts", required=True, help="Contexts JSON string or path to JSON file.")
|
|
37
|
+
@click.option("--name", default=None, help="Name for the .deepcell file (default: input filename).")
|
|
38
|
+
@click.option("--workspace", default=None, help="Target workspace slug.")
|
|
39
|
+
@click.option("--aggregations", default=None, help="Aggregation rules JSON string or path to JSON file.")
|
|
40
|
+
@click.option("--keep-raw", is_flag=True, default=False, help="Store raw values alongside aggregated results.")
|
|
41
|
+
@pass_ctx
|
|
42
|
+
def import_cmd(
|
|
43
|
+
ctx: Ctx,
|
|
44
|
+
file: str,
|
|
45
|
+
items: str,
|
|
46
|
+
contexts: str,
|
|
47
|
+
name: str | None,
|
|
48
|
+
workspace: str | None,
|
|
49
|
+
aggregations: str | None,
|
|
50
|
+
keep_raw: bool,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Import an xlsx or csv file into a .deepcell document.
|
|
53
|
+
|
|
54
|
+
Requires structured analysis as JSON: items (with row numbers) and
|
|
55
|
+
contexts (with column letters). The cell map is auto-generated from
|
|
56
|
+
the item rows × context columns cross-product. The result is saved as
|
|
57
|
+
NAME.deepcell (--name, default: the input file's stem) and replaces a
|
|
58
|
+
file of that name — import creates; it does not merge into an existing
|
|
59
|
+
document.
|
|
60
|
+
|
|
61
|
+
Use --aggregations to specify custom mapping rules that aggregate
|
|
62
|
+
source values into target coordinates. Only aggregated results are
|
|
63
|
+
stored unless --keep-raw is also provided.
|
|
64
|
+
|
|
65
|
+
\b
|
|
66
|
+
An items entry takes:
|
|
67
|
+
id, name, level required
|
|
68
|
+
parent_id hierarchy
|
|
69
|
+
data_type monetary | percentage | ratio | quantity |
|
|
70
|
+
count | number | text | date — the set
|
|
71
|
+
`deepcell ref datatype` documents. Anything
|
|
72
|
+
else is stored verbatim with a warning and
|
|
73
|
+
renders as plain text.
|
|
74
|
+
scale, currency REQUIRED on a monetary item (house rule R7) —
|
|
75
|
+
scale is the power of ten the values are
|
|
76
|
+
stated in (3 = thousands), currency an ISO
|
|
77
|
+
4217 code. Omit them and every monetary row
|
|
78
|
+
fails `describe --lint`, because 4200 could
|
|
79
|
+
be dollars, thousands or millions.
|
|
80
|
+
row, sheet where the data lives in the workbook. A
|
|
81
|
+
multi-sheet workbook needs `sheet` on EVERY
|
|
82
|
+
item: one without it is read from the first
|
|
83
|
+
item's sheet (else Sheet1) — silently the
|
|
84
|
+
wrong tab, or a skipped cell.
|
|
85
|
+
|
|
86
|
+
\b
|
|
87
|
+
A contexts entry takes:
|
|
88
|
+
id, name, period_type required (annual | quarterly | monthly | custom)
|
|
89
|
+
status which status this column belongs to
|
|
90
|
+
status_archetype what that status IS (R13): actual |
|
|
91
|
+
preliminary | restated | estimate | guidance
|
|
92
|
+
| consensus | forecast | budget | plan |
|
|
93
|
+
target. `deepcell ref status` lists them.
|
|
94
|
+
state closed | open | future (R13) — period kinds only
|
|
95
|
+
start_date, end_date ISO dates
|
|
96
|
+
column workbook column letter — a letter only, no
|
|
97
|
+
sheet. Every context column is read on every
|
|
98
|
+
item's sheet, so one import carries ONE column
|
|
99
|
+
layout. A tab whose periods sit in other
|
|
100
|
+
columns is a second pass into the same file
|
|
101
|
+
via `deepcell edit FILE --batch`, never a
|
|
102
|
+
second import under another name (rule R3).
|
|
103
|
+
kind period (default) or a non-temporal label
|
|
104
|
+
|
|
105
|
+
Everything R7 and R13 require can be stated at import time. Leaving them
|
|
106
|
+
out means a follow-up `defs update-item` per item and `defs update-context`
|
|
107
|
+
per context before the document is rule-clean.
|
|
108
|
+
|
|
109
|
+
Provenance: one <Source kind="workbook"> with a <Cites at="sheet:TAB!B5">
|
|
110
|
+
row per mapped cell (item row × context column). `deepcell guide ingest/tabular` covers what to do
|
|
111
|
+
after that.
|
|
112
|
+
"""
|
|
113
|
+
file_path = Path(file)
|
|
114
|
+
if name is None:
|
|
115
|
+
name = file_path.stem
|
|
116
|
+
|
|
117
|
+
# Parse JSON arguments
|
|
118
|
+
items_data = _load_json_arg(items)
|
|
119
|
+
contexts_data = _load_json_arg(contexts)
|
|
120
|
+
|
|
121
|
+
# Same check `defs add-item --data-type` runs. The backend stores the
|
|
122
|
+
# type verbatim, so a typo (`moneytary`) or the retired `string` would
|
|
123
|
+
# otherwise land in the file and every formatter would silently fall back
|
|
124
|
+
# to its default. A warning, not a rejection — the vocabulary is open.
|
|
125
|
+
if isinstance(items_data, list):
|
|
126
|
+
for index, entry in enumerate(items_data):
|
|
127
|
+
if not isinstance(entry, dict):
|
|
128
|
+
continue
|
|
129
|
+
message = warn_unrecognized_data_type(entry.get("data_type"))
|
|
130
|
+
if message:
|
|
131
|
+
label = entry.get("id") or f"items[{index}]"
|
|
132
|
+
echo_warning(f"{label}: {message}")
|
|
133
|
+
|
|
134
|
+
fields: dict[str, str] = {
|
|
135
|
+
"items": json.dumps(items_data),
|
|
136
|
+
"contexts": json.dumps(contexts_data),
|
|
137
|
+
"name": name,
|
|
138
|
+
"workspace_slug": workspace or ctx.require_workspace(),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if aggregations:
|
|
142
|
+
agg_data = _load_json_arg(aggregations)
|
|
143
|
+
fields["aggregations"] = json.dumps(agg_data)
|
|
144
|
+
|
|
145
|
+
if keep_raw:
|
|
146
|
+
fields["keep_raw"] = "true"
|
|
147
|
+
|
|
148
|
+
# Send to backend
|
|
149
|
+
try:
|
|
150
|
+
result = ctx.client.post_multipart(
|
|
151
|
+
"/import",
|
|
152
|
+
file_path=str(file_path),
|
|
153
|
+
file_name=file_path.name,
|
|
154
|
+
fields=fields,
|
|
155
|
+
)
|
|
156
|
+
except click.ClickException:
|
|
157
|
+
raise
|
|
158
|
+
except Exception as e:
|
|
159
|
+
raise click.ClickException(f"Import failed: {e}")
|
|
160
|
+
|
|
161
|
+
skipped = result.get("skipped_values") or []
|
|
162
|
+
|
|
163
|
+
# Handle JSON output format
|
|
164
|
+
if ctx.fmt == "json":
|
|
165
|
+
click.echo(json.dumps(result, indent=2))
|
|
166
|
+
if skipped:
|
|
167
|
+
raise click.exceptions.Exit(1)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Display result
|
|
171
|
+
summary = result.get("summary", {})
|
|
172
|
+
dest = result.get("file_path", name)
|
|
173
|
+
ws = result.get("workspace", "default")
|
|
174
|
+
|
|
175
|
+
echo_success(f"Imported {file_path.name} -> {dest}")
|
|
176
|
+
click.echo(f" Workspace: {ws}", err=True)
|
|
177
|
+
click.echo(f" Items: {summary.get('items', 0)}", err=True)
|
|
178
|
+
click.echo(f" Contexts: {summary.get('contexts', 0)}", err=True)
|
|
179
|
+
click.echo(f" Values: {summary.get('values', 0)}", err=True)
|
|
180
|
+
|
|
181
|
+
fc = summary.get("formulas_converted", 0)
|
|
182
|
+
fw = summary.get("formulas_warnings", 0)
|
|
183
|
+
if fc or fw:
|
|
184
|
+
click.echo(f" Formulas: {fc} converted, {fw} warnings", err=True)
|
|
185
|
+
|
|
186
|
+
# Show warnings
|
|
187
|
+
for w in result.get("warnings", []):
|
|
188
|
+
echo_warning(f"{w.get('sheet', '')}:{w.get('cell', '')} -- {w.get('issue', '')}")
|
|
189
|
+
|
|
190
|
+
for s in skipped:
|
|
191
|
+
echo_warning(f"Skipped {s.get('sheet', '')}:{s.get('cell', '')} ({s.get('item_id', '')}) -- {s.get('issue', '')}")
|
|
192
|
+
|
|
193
|
+
if aggregations and not keep_raw:
|
|
194
|
+
echo_warning(
|
|
195
|
+
"Raw source values were not stored. "
|
|
196
|
+
"Use --keep-raw to retain source values alongside aggregated results."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if skipped:
|
|
200
|
+
# Skipped cells = data missing from the imported model. The import is
|
|
201
|
+
# committed, but it must not read as a clean success (same contract as
|
|
202
|
+
# `variant merge`, which exits 1 on had_data_loss).
|
|
203
|
+
echo_warning(
|
|
204
|
+
f"{len(skipped)} source value(s) skipped — the imported model is "
|
|
205
|
+
"missing data; fix the cells above and re-import, or fill them "
|
|
206
|
+
"with `deepcell edit`"
|
|
207
|
+
)
|
|
208
|
+
raise click.exceptions.Exit(1)
|